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:
parent
f13ff205ce
commit
9e35e1eef2
5 changed files with 775 additions and 50 deletions
268
assets/js/app.ts
268
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<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
169
lib/towerops/alerts/notification_rate_limiter.ex
Normal file
169
lib/towerops/alerts/notification_rate_limiter.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue