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
This commit is contained in:
Graham McIntire 2026-03-15 14:06:06 -05:00
parent f13ff205ce
commit 9e35e1eef2
5 changed files with 775 additions and 50 deletions

View file

@ -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<string, string> = {
good: '#10B981', // green
degraded: '#F59E0B', // yellow
critical: '#EF4444' // red
}
// Edge styling: prefer signal_health coloring for RF links, fall back to confidence
const getEdgeStyle = (confidence: number, signalHealth?: string) => {
if (signalHealth && signalHealthColors[signalHealth]) {
return { color: signalHealthColors[signalHealth], style: 'solid' as const, dashPattern: [] }
}
if (confidence > 0.9) {
return { color: '#10B981', style: 'solid', 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<string, {x: number, y: number}> = {}
const latRange = maxLat - minLat || 1
const lngRange = maxLng - minLng || 1
;(topology.nodes || []).forEach((n: any) => {
if (n.latitude != null && n.longitude != null) {
positions[n.id] = {
x: 50 + ((n.longitude - minLng) / lngRange) * width,
y: 50 + ((maxLat - n.latitude) / latRange) * height // invert Y for map orientation
}
}
})
// Nodes without geo get placed at the bottom
let offsetX = 0
;(topology.nodes || []).forEach((n: any) => {
if (n.type === 'site') return
if (!positions[n.id]) {
// If node has a parent site with geo, place near it
if (n.parent && positions[n.parent]) {
positions[n.id] = {
x: positions[n.parent].x + (Math.random() - 0.5) * 60,
y: positions[n.parent].y + (Math.random() - 0.5) * 60
}
} else {
positions[n.id] = { x: offsetX * 80, y: height + 100 }
offsetX++
}
}
})
this.cy.layout({
name: 'preset',
positions: (node: any) => positions[node.id()] || { x: 0, y: 0 },
fit: true,
padding: 50,
animate: true,
animationDuration: 500
}).run()
} else {
// Force-directed (default preset layout)
const allNodes = topology.nodes || []
const newPositions = this._computePresetPositions(allNodes)
this.cy.layout({
name: 'preset',
positions: (node: any) => newPositions[node.id()] || { x: 0, y: 0 },
fit: true,
padding: 50,
animate: true,
animationDuration: 500
}).run()
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -150,7 +150,113 @@
</div>
</div>
<!-- Network Map Container -->
<!-- Filter Bar -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10 mb-4 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="degraded"
class={[
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
if(@filter == "degraded",
do: "bg-yellow-100 text-yellow-700 dark:bg-yellow-400/20 dark:text-yellow-400",
else: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
)
]}
>
Degraded Only
<%= if @topology.stats[:degraded_links] && @topology.stats.degraded_links > 0 do %>
<span class="ml-1 inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-yellow-800 bg-yellow-200 rounded-full dark:bg-yellow-900 dark:text-yellow-300">
{@topology.stats.degraded_links}
</span>
<% end %>
</button>
<button
phx-click="set_filter"
phx-value-filter="alerts"
class={[
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
if(@filter == "alerts",
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"
)
]}
>
Sites with Alerts
<%= if @topology.stats[:sites_with_alerts] && @topology.stats.sites_with_alerts > 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.sites_with_alerts}
</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 Map Container -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="p-4 border-b border-gray-200 dark:border-white/10">
<div class="flex items-center justify-between">
@ -295,6 +401,56 @@
</div>
<% end %>
</div>
<!-- Wireless Stats (for APs, CPEs, backhaul radios) -->
<%= if @selected_node_detail[:is_wireless] 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">
RF Stats
</h4>
<div class="grid grid-cols-2 gap-2">
<!-- Client count -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-md p-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Clients</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-1">
<.icon name="hero-wifi" class="h-4 w-4 text-purple-500" />
{@selected_node_detail[:client_count] || 0}
</div>
</div>
<!-- Signal health -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-md p-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Signal</div>
<div class={[
"text-sm font-semibold capitalize",
case @selected_node_detail[:signal_health] do
"good" -> "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"}
</div>
</div>
<!-- SNR -->
<%= if @selected_node_detail[:snr] do %>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-md p-2 col-span-2">
<div class="text-xs text-gray-500 dark:text-gray-400">SNR</div>
<div class={[
"text-sm font-semibold",
cond do
@selected_node_detail.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
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<!-- Connections -->
<%= if length(@selected_node_detail.connections) > 0 do %>
@ -338,7 +494,7 @@
<!-- View Device Link (managed only) -->
<%= if @selected_node_detail.type == :managed do %>
<div class="mt-4">
<div class="mt-4 space-y-2">
<.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")}
</.link>
<%= 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")}
</.link>
<% end %>
</div>
<% end %>
</div>
@ -373,7 +538,7 @@
style="border-bottom-color: #8B5CF6;"
>
</div>
<span class="text-gray-700 dark:text-gray-300">Wireless</span>
<span class="text-gray-700 dark:text-gray-300">AP / Wireless</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-3 h-3 rounded-full" style="background-color: #EF4444;"></div>
@ -393,22 +558,36 @@
<span class="text-gray-700 dark:text-gray-300">Discovered</span>
</div>
</div>
<%!-- Link confidence --%>
<%!-- RF Link health --%>
<div class="flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="font-medium text-gray-600 dark:text-gray-400">Links:</span>
<span class="font-medium text-gray-600 dark:text-gray-400">RF Links:</span>
<div class="flex items-center space-x-1.5">
<div class="w-6 h-0.5" style="background-color: #10B981;"></div>
<span class="text-gray-700 dark:text-gray-300">High confidence</span>
<div class="w-6 h-1 rounded" style="background-color: #10B981;"></div>
<span class="text-gray-700 dark:text-gray-300">Good signal</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-6 h-0.5 border-t-2 border-dashed" style="border-color: #F59E0B;">
</div>
<span class="text-gray-700 dark:text-gray-300">Medium confidence</span>
<div class="w-6 h-1 rounded" style="background-color: #F59E0B;"></div>
<span class="text-gray-700 dark:text-gray-300">Degraded</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-6 h-0.5 border-t-2 border-dotted" style="border-color: #9CA3AF;">
<div class="w-6 h-1 rounded" style="background-color: #EF4444;"></div>
<span class="text-gray-700 dark:text-gray-300">Critical</span>
</div>
</div>
<%!-- Link types --%>
<div class="flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="font-medium text-gray-600 dark:text-gray-400">Link types:</span>
<div class="flex items-center space-x-1.5">
<div class="w-6 h-0.5" style="background-color: #6B7280;"></div>
<span class="text-gray-700 dark:text-gray-300">LLDP/CDP</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-6 h-0.5 border-t-2 border-dashed" style="border-color: #6B7280;">
</div>
<span class="text-gray-700 dark:text-gray-300">Low confidence</span>
<span class="text-gray-700 dark:text-gray-300">MAC/ARP inferred</span>
</div>
<div class="flex items-center space-x-1.5">
<span class="text-gray-500 dark:text-gray-400">Line thickness = bandwidth capacity</span>
</div>
</div>
</div>