feat: Add network weathermap with real-time utilization visualization (#172)

- Create WeathermapLive module at /weathermap with 60s auto-refresh
- Extend Topology.get_topology_for_weathermap with bandwidth utilization data
- Add WeathermapViewer JavaScript hook with color-coded edges:
  * Green (0-30%) → Yellow (30-60%) → Orange (60-80%) → Red (80%+)
- Display throughput/capacity labels on edges (e.g., '847 Mbps / 1 Gbps')
- Add utilization stats dashboard with link count by usage level
- Support full-screen mode for NOC displays
- Integrate with existing dark mode and responsive design
- Add navigation links in sidebar and mobile menu

Technical implementation:
- Leverages existing Capacity module for interface utilization calculations
- Extends topology edges with utilization_level, utilization_pct, throughput_bps
- Builds on NetworkMapLive foundation with enhanced edge styling
- Auto-refresh via Phoenix PubSub and 60s timer
- Filter support for high/critical utilization links

Reviewed-on: graham/towerops-web#172
This commit is contained in:
Graham McIntire 2026-03-25 16:26:11 -05:00 committed by graham
parent f442c59ad0
commit a97340873f
6 changed files with 1547 additions and 1 deletions

View file

@ -1825,10 +1825,486 @@ const ThemeSelector = {
},
}
// WeathermapViewer hook - extends NetworkMap with utilization-based edge coloring
const WeathermapViewer = {
cy: null as any,
_topologyData: null as any,
mounted(this: any) {
const topologyData = JSON.parse(this.el.dataset.topology || '{}')
this._topologyData = topologyData
this.initializeCytoscape(topologyData)
// Listen for weathermap updates from LiveView
this.handleEvent("update_weathermap", (payload: any) => {
if (this.cy) {
this._topologyData = payload.topology
this.updateTopology(payload.topology)
}
})
// Filter handler
this.handleEvent("apply_filter", (payload: any) => {
if (!this.cy) return
this._applyFilter(payload.filter)
})
// Search handler
this.handleEvent("search_nodes", (payload: any) => {
if (!this.cy) return
this._searchNodes(payload.query)
})
// Layout toggle handler
this.handleEvent("change_layout", (payload: any) => {
if (!this.cy || !this._topologyData) return
this._changeLayout(payload.mode, this._topologyData)
})
},
updated(this: any) {
const topologyData = JSON.parse(this.el.dataset.topology || '{}')
this.updateTopology(topologyData)
},
destroyed(this: any) {
if (this.cy) {
try {
this.cy.stop()
} catch (_e) { /* ignore */ }
this.cy.destroy()
this.cy = null
}
},
initializeCytoscape(this: any, topology: any) {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
// Role-based node colors
const roleColors: Record<string, string> = {
router: '#3B82F6', // blue
switch: '#10B981', // green
wireless: '#8B5CF6', // purple
firewall: '#EF4444', // red
server: '#F59E0B', // amber
unknown: '#9CA3AF' // light gray
}
// Role-based node shapes
const roleShapes: Record<string, string> = {
router: 'round-rectangle',
switch: 'diamond',
wireless: 'triangle',
firewall: 'hexagon',
server: 'rectangle',
unknown: 'ellipse'
}
// Utilization level colors for weathermap edges
const utilizationColors: Record<string, string> = {
low: '#10B981', // green (0-30%)
medium: '#F59E0B', // yellow (30-60%)
high: '#F97316', // orange (60-80%)
critical: '#EF4444' // red (80%+)
}
// Edge styling based on utilization level
const getWeathermapEdgeStyle = (edge: any) => {
const utilizationLevel = edge.utilization_level
const confidence = edge.confidence || 0.5
if (utilizationLevel && utilizationColors[utilizationLevel]) {
return {
color: utilizationColors[utilizationLevel],
style: 'solid' as const,
dashPattern: []
}
}
// Fallback to confidence-based styling if no utilization data
if (confidence > 0.9) {
return { color: '#9CA3AF', style: 'solid' as const, dashPattern: [] }
} else if (confidence >= 0.5) {
return { color: '#9CA3AF', style: 'dashed' as const, dashPattern: [6, 3] }
} else {
return { color: '#D1D5DB', style: 'dashed' as const, dashPattern: [2, 3] }
}
}
// Use simplified position computation
const computeSimplePositions = (nodes: any[]): Record<string, {x: number, y: number}> => {
const positions: Record<string, {x: number, y: number}> = {}
const SPACING = 120
const cols = Math.ceil(Math.sqrt(nodes.filter(n => n.type !== 'site').length))
let x = 0, y = 0, col = 0
nodes.forEach((node, i) => {
if (node.type === 'site') return
positions[node.id] = { x: x * SPACING, y: y * SPACING }
col++
if (col >= cols) {
col = 0
x = 0
y++
} else {
x++
}
})
return positions
}
const presetPositions = computeSimplePositions(topology.nodes || [])
// Build Cytoscape elements
const elements: any[] = []
// Add nodes (skip site nodes for now in simplified version)
topology.nodes?.forEach((node: any) => {
if (node.type === 'site') return // Skip site nodes in simplified version
const data: any = {
id: node.id,
label: node.label,
type: node.type,
status: node.status,
discovered: node.discovered,
ip_address: node.ip_address,
site_name: node.site_name,
manufacturer: node.manufacturer,
client_count: node.client_count || 0,
signal_health: node.signal_health,
has_alerts: node.has_alerts || false,
device_role: node.device_role
}
elements.push({
data: data,
classes: node.discovered ? 'discovered' : node.status
})
})
// Add edges with weathermap-specific data
topology.edges?.forEach((edge: any) => {
// Build edge label with throughput/capacity info
let label = ''
if (edge.utilization_text) {
label = edge.utilization_text
} else if (edge.source_interface && edge.target_interface) {
label = `${edge.source_interface}${edge.target_interface}`
}
elements.push({
data: {
id: edge.id,
source: edge.source,
target: edge.target,
source_interface: edge.source_interface,
target_interface: edge.target_interface,
link_type: edge.link_type,
confidence: edge.confidence ?? 0.5,
if_speed: edge.if_speed,
utilization_level: edge.utilization_level,
utilization_pct: edge.utilization_pct,
utilization_text: edge.utilization_text,
throughput_bps: edge.throughput_bps,
capacity_bps: edge.capacity_bps,
signal_health: edge.signal_health,
snr: edge.snr,
label: label
}
})
})
// Map interface speed to edge width
const getEdgeWidth = (speed: number | null | undefined): number => {
if (!speed) return 2
if (speed >= 10_000_000_000) return 6 // 10Gbps+
if (speed >= 1_000_000_000) return 4 // 1Gbps
if (speed >= 100_000_000) return 3 // 100Mbps
return 2 // < 100Mbps or unknown
}
// Initialize Cytoscape
this.cy = cytoscape({
container: this.el,
elements: elements,
style: [
{
selector: 'node',
style: {
'background-color': function(ele: any) {
return roleColors[ele.data('type')] || roleColors.unknown
},
'shape': function(ele: any) {
return roleShapes[ele.data('type')] || roleShapes.unknown
},
'label': 'data(label)',
'color': isDark ? '#fff' : '#000',
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': 6,
'font-size': '13px',
'font-weight': '500',
'text-wrap': 'wrap',
'text-max-width': '140px',
'width': 44,
'height': 44,
'border-width': 2,
'border-color': function(ele: any) {
const status = ele.data('status')
if (status === 'down') return '#dc2626'
if (status === 'unknown') return '#ca8a04'
const type = ele.data('type')
const darkerColors: Record<string, string> = {
router: '#2563EB',
switch: '#059669',
wireless: '#7C3AED',
firewall: '#DC2626',
server: '#D97706',
unknown: '#6B7280'
}
return darkerColors[type] || darkerColors.unknown
}
} as any
},
{
selector: 'node[status = "down"]',
style: {
'border-width': 3,
'border-color': '#dc2626',
'background-blacken': -0.3
}
},
{
selector: 'node.discovered',
style: {
'border-style': 'dashed',
'border-width': 2,
'border-color': '#9ca3af',
'opacity': 0.6,
'width': 34,
'height': 34,
'font-size': '11px'
}
},
{
selector: 'edge',
style: {
'width': function(ele: any) {
return getEdgeWidth(ele.data('if_speed'))
},
'line-color': function(ele: any) {
return getWeathermapEdgeStyle(ele.data()).color
},
'line-style': function(ele: any) {
return getWeathermapEdgeStyle(ele.data()).style
},
'line-dash-pattern': function(ele: any) {
const pattern = getWeathermapEdgeStyle(ele.data()).dashPattern
return pattern.length > 0 ? pattern : [1, 0]
},
'curve-style': 'bezier',
'label': 'data(label)',
'font-size': '9px',
'color': isDark ? '#d1d5db' : '#4b5563',
'text-background-color': isDark ? '#1f2937' : '#ffffff',
'text-background-opacity': 0.9,
'text-background-padding': '3px',
'text-rotation': 'autorotate',
'text-margin-y': -10
} as any
}
],
layout: {
name: 'preset',
positions: (node: any) => presetPositions[node.id()] || { x: 0, y: 0 },
fit: true,
padding: 50
},
minZoom: 0.2,
maxZoom: 3,
wheelSensitivity: 1
})
// Add click handler for nodes
this.cy.on('tap', 'node', (evt: any) => {
const node = evt.target
const nodeData = node.data()
this.pushEvent("node_clicked", {
node_id: nodeData.id,
node_type: nodeData.discovered ? 'discovered' : 'managed'
})
})
// Handle highlight and clear highlight events
this.handleEvent("highlight_node", (payload: any) => {
if (!this.cy) return
this.cy.nodes().removeClass('highlighted')
const node = this.cy.getElementById(payload.node_id)
if (node.length > 0) {
node.addClass('highlighted')
}
})
this.handleEvent("clear_highlight", () => {
if (!this.cy) return
this.cy.nodes().removeClass('highlighted')
})
// Enhanced hover tooltips with utilization info
this.cy.on('mouseover', 'edge', (evt: any) => {
const edge = evt.target
const data = edge.data()
const srcNode = this.cy.getElementById(data.source)
const tgtNode = this.cy.getElementById(data.target)
const srcName = srcNode.data('label') || data.source
const tgtName = tgtNode.data('label') || data.target
let tooltip = `${srcName}${tgtName}`
if (data.utilization_text) {
tooltip += `\n🚦 ${data.utilization_text}`
if (data.utilization_pct != null) {
tooltip += ` (${data.utilization_pct}%)`
}
}
edge.style({
'line-color': '#fbbf24',
'width': Math.max(getEdgeWidth(data.if_speed), 4),
'label': tooltip
})
})
this.cy.on('mouseout', 'edge', (evt: any) => {
const edge = evt.target
const data = edge.data()
const edgeStyle = getWeathermapEdgeStyle(data)
edge.style({
'line-color': edgeStyle.color,
'width': getEdgeWidth(data.if_speed),
'label': data.label || ''
})
})
this._initControls()
},
_initControls(this: any) {
const ZOOM_STEP = 0.25
const zoomIn = document.getElementById('cy-zoom-in')
const zoomOut = document.getElementById('cy-zoom-out')
const fit = document.getElementById('cy-fit')
if (zoomIn) {
zoomIn.addEventListener('click', () => {
if (!this.cy) return
this.cy.zoom({
level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()),
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
})
})
}
if (zoomOut) {
zoomOut.addEventListener('click', () => {
if (!this.cy) return
this.cy.zoom({
level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()),
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
})
})
}
if (fit) {
fit.addEventListener('click', () => {
if (!this.cy) return
this.cy.fit(undefined, 50)
})
}
},
updateTopology(this: any, topology: any) {
if (!this.cy) return
// For now, just recreate the entire topology (can be optimized later)
this.cy.destroy()
this.initializeCytoscape(topology)
},
_applyFilter(this: any, filter: string) {
if (!this.cy) return
// Show all first
this.cy.elements().style('display', 'element')
if (filter === 'high_util') {
// Show only high and critical utilization edges and their connected nodes
const matchEdges = this.cy.edges().filter((e: any) => {
const level = e.data('utilization_level')
return level === 'high' || level === 'critical'
})
const connectedNodes = matchEdges.connectedNodes()
const visible = matchEdges.union(connectedNodes)
this.cy.elements().not(visible).style('display', 'none')
} else if (filter === 'critical') {
// Show only critical utilization edges and their connected nodes
const matchEdges = this.cy.edges().filter((e: any) => {
const level = e.data('utilization_level')
return level === 'critical'
})
const connectedNodes = matchEdges.connectedNodes()
const visible = matchEdges.union(connectedNodes)
this.cy.elements().not(visible).style('display', 'none')
}
},
_searchNodes(this: any, query: string) {
if (!this.cy) return
this.cy.nodes().removeClass('search-match')
if (!query || query.trim() === '') return
const q = query.toLowerCase()
const matches = this.cy.nodes().filter((n: any) => {
const d = n.data()
return (d.label && d.label.toLowerCase().includes(q)) ||
(d.ip_address && d.ip_address.toLowerCase().includes(q)) ||
(d.site_name && d.site_name.toLowerCase().includes(q)) ||
(d.manufacturer && d.manufacturer.toLowerCase().includes(q))
})
matches.addClass('search-match')
if (matches.length > 0) {
this.cy.fit(matches, 80)
}
},
_changeLayout(this: any, mode: string, topology: any) {
// Simplified layout for now
if (!this.cy) return
this.cy.layout({
name: 'circle',
animate: true,
animationDuration: 500
}).run()
}
}
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 5000,
params: { _csrf_token: csrfToken, timezone: userTimezone },
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, SitesMap, LeafletMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, LeafletMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
})
// Show progress bar on live navigation and form submits

View file

@ -22,6 +22,7 @@ defmodule Towerops.Topology do
alias Towerops.Topology.DeviceNeighbor
alias Towerops.Topology.Identifier
alias Towerops.Topology.Lldp
alias Towerops.Capacity
require Logger
@ -491,6 +492,94 @@ defmodule Towerops.Topology do
}
end
@doc """
Build topology data for the network weathermap with edge utilization info.
Returns %{nodes, edges, stats, last_updated} with utilization data.
Tab "added" = managed devices only. Tab "all" = managed + discovered.
"""
def get_topology_for_weathermap(organization_id, tab \\ "added") do
devices = list_managed_devices(organization_id)
device_ids = Enum.map(devices, & &1.id)
links = list_links_for_devices(device_ids)
# Compute wireless stats (client counts + signal health) per device
wireless_stats = compute_wireless_stats(device_ids)
# Compute RF signal health per link for edge enrichment
rf_link_stats = compute_rf_link_stats(device_ids)
managed_nodes = Enum.map(devices, &device_to_node(&1, wireless_stats))
discovered_nodes = build_discovered_nodes(links, tab)
# Build site compound nodes from devices that have sites
site_nodes =
devices
|> Enum.filter(& &1.site)
|> Enum.uniq_by(& &1.site_id)
|> Enum.map(fn device ->
%{
id: "site_#{device.site_id}",
label: device.site.name,
type: :site,
device_role: nil,
status: nil,
site_id: device.site_id,
site_name: device.site.name,
ip_address: nil,
discovered: false,
manufacturer: nil,
parent: nil,
latitude: device.site.latitude,
longitude: device.site.longitude
}
end)
all_nodes = site_nodes ++ managed_nodes ++ discovered_nodes
node_ids = MapSet.new(Enum.map(all_nodes, & &1.id))
# Build edges with utilization data
edges =
links
|> build_edges_from_links(node_ids)
|> merge_bidirectional_edges()
|> enrich_edges_with_rf(rf_link_stats)
|> enrich_edges_with_utilization(device_ids)
# Compute utilization stats for the dashboard
utilization_stats = compute_utilization_stats(edges)
# Compute whether geographic layout is available
has_geo =
Enum.any?(all_nodes, fn n ->
n[:latitude] != nil and n[:longitude] != nil
end)
%{
nodes: all_nodes,
edges: edges,
stats: %{
total_devices: length(managed_nodes) + length(discovered_nodes),
added_devices: length(managed_nodes),
discovered_devices: length(discovered_nodes),
total_links: length(edges),
low_utilization_links: utilization_stats.low_utilization_links,
medium_utilization_links: utilization_stats.medium_utilization_links,
high_utilization_links: utilization_stats.high_utilization_links,
overutilized_links: utilization_stats.overutilized_links,
degraded_links: Enum.count(edges, fn e -> e[:signal_health] == "degraded" or e[:signal_health] == "critical" end),
sites_with_alerts:
devices
|> Enum.filter(&(&1.status == :down))
|> Enum.map(& &1.site_id)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> length()
},
has_geo: has_geo,
last_updated: DateTime.utc_now()
}
end
@doc """
Get detail information for a node (managed or discovered) for the detail panel.
@ -782,6 +871,8 @@ defmodule Towerops.Topology do
target: target_id,
source_interface: if(link.source_interface, do: link.source_interface.if_name),
target_interface: if(link.target_interface, do: link.target_interface.if_name),
source_interface_id: link.source_interface_id,
target_interface_id: if(link.target_interface, do: link.target_interface.id),
link_type: link.link_type,
confidence: link.confidence,
metadata: link.metadata,
@ -1305,4 +1396,163 @@ defmodule Towerops.Topology do
priority = %{"good" => 0, "degraded" => 1, "critical" => 2}
if (priority[a] || 0) >= (priority[b] || 0), do: a, else: b
end
# Enriches edges with bandwidth utilization data by calculating interface throughput
# and comparing it to configured capacity.
defp enrich_edges_with_utilization(edges, device_ids) do
# Get all interfaces with configured capacity for these devices
interface_utilizations = get_interface_utilizations(device_ids)
Enum.map(edges, fn edge ->
# Try to find utilization data for the source or target interface
source_util = get_edge_utilization(edge, :source, interface_utilizations)
target_util = get_edge_utilization(edge, :target, interface_utilizations)
# Use the higher utilization of the two interfaces
utilization = combine_interface_utilizations(source_util, target_util)
Map.merge(edge, utilization)
end)
end
defp get_interface_utilizations(device_ids) do
# Query interfaces with capacity configuration and recent stats
Repo.all(
from i in Interface,
join: sd in SnmpDevice, on: i.snmp_device_id == sd.id,
join: d in Device, on: sd.device_id == d.id,
where: d.id in ^device_ids and not is_nil(i.configured_capacity_bps),
select: %{
interface_id: i.id,
device_id: d.id,
interface_name: i.if_name,
capacity_bps: i.configured_capacity_bps,
if_index: i.if_index
}
)
|> Enum.map(fn interface ->
# Calculate current utilization using the Capacity module
interface_struct = %Interface{
id: interface.interface_id,
configured_capacity_bps: interface.capacity_bps
}
utilization = Capacity.get_utilization(interface_struct)
Map.merge(interface, %{
utilization_data: utilization
})
end)
|> Map.new(fn interface -> {interface.interface_id, interface} end)
end
defp get_edge_utilization(edge, direction, interface_utilizations) do
interface_id =
case direction do
:source -> edge[:source_interface_id]
:target -> edge[:target_interface_id]
end
case interface_id do
nil -> nil
id -> Map.get(interface_utilizations, id)
end
end
defp combine_interface_utilizations(source_util, target_util) do
case {source_util, target_util} do
{nil, nil} ->
%{
utilization_pct: nil,
utilization_level: nil,
throughput_bps: nil,
capacity_bps: nil,
utilization_text: nil
}
{util, nil} ->
process_single_utilization(util)
{nil, util} ->
process_single_utilization(util)
{source, target} ->
# Use the higher utilization of the two interfaces
source_data = process_single_utilization(source)
target_data = process_single_utilization(target)
if (source_data.utilization_pct || 0) >= (target_data.utilization_pct || 0) do
source_data
else
target_data
end
end
end
defp process_single_utilization(util_data) do
case util_data.utilization_data do
nil ->
%{
utilization_pct: nil,
utilization_level: nil,
throughput_bps: nil,
capacity_bps: util_data.capacity_bps,
utilization_text: nil
}
util ->
utilization_pct = Float.round(util.utilization_pct, 1)
level = classify_utilization_level(utilization_pct)
%{
utilization_pct: utilization_pct,
utilization_level: level,
throughput_bps: util.throughput.max_bps,
capacity_bps: util_data.capacity_bps,
utilization_text: format_utilization_text(util.throughput.max_bps, util_data.capacity_bps),
interface_name: util_data.interface_name
}
end
end
defp classify_utilization_level(nil), do: nil
defp classify_utilization_level(pct) when pct < 30, do: :low
defp classify_utilization_level(pct) when pct < 60, do: :medium
defp classify_utilization_level(pct) when pct < 80, do: :high
defp classify_utilization_level(_pct), do: :critical
defp format_utilization_text(throughput_bps, capacity_bps) do
throughput_text = format_bps(throughput_bps)
capacity_text = format_bps(capacity_bps)
"#{throughput_text} / #{capacity_text}"
end
defp format_bps(nil), do: "N/A"
defp format_bps(bps) when bps >= 1_000_000_000 do
"#{Float.round(bps / 1_000_000_000, 1)} Gbps"
end
defp format_bps(bps) when bps >= 1_000_000 do
"#{Float.round(bps / 1_000_000, 0)} Mbps"
end
defp format_bps(bps) when bps >= 1_000 do
"#{Float.round(bps / 1_000, 0)} Kbps"
end
defp format_bps(bps) do
"#{Float.round(bps, 0)} bps"
end
# Compute utilization statistics for the weathermap dashboard.
defp compute_utilization_stats(edges) do
utilization_counts =
edges
|> Enum.map(& &1[:utilization_level])
|> Enum.frequencies()
%{
low_utilization_links: Map.get(utilization_counts, :low, 0),
medium_utilization_links: Map.get(utilization_counts, :medium, 0),
high_utilization_links: Map.get(utilization_counts, :high, 0),
overutilized_links: Map.get(utilization_counts, :critical, 0)
}
end
end

View file

@ -382,6 +382,12 @@ defmodule ToweropsWeb.Layouts do
icon="hero-map"
label={t("Network Map")}
/>
<.sidebar_link
navigate={~p"/weathermap"}
active={@active_page == "weathermap"}
icon="hero-signal"
label={t("Weathermap")}
/>
<!-- RESPOND section -->
<p
@ -587,6 +593,9 @@ defmodule ToweropsWeb.Layouts do
<.mobile_nav_link navigate={~p"/network-map"} active={@active_page == "network-map"}>
<.icon name="hero-map" class="size-5" /> {t("Network Map")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/weathermap"} active={@active_page == "weathermap"}>
<.icon name="hero-signal" class="size-5" /> {t("Weathermap")}
</.mobile_nav_link>
</div>
<!-- RESPOND -->

View file

@ -0,0 +1,195 @@
defmodule ToweropsWeb.WeathermapLive do
@moduledoc """
Live view for real-time network utilization weathermap.
Displays network topology with color-coded edges based on bandwidth utilization.
"""
use ToweropsWeb, :live_view
alias Towerops.Topology
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
# Subscribe to topology changes and capacity updates if connected
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "topology:#{organization.id}")
Phoenix.PubSub.subscribe(Towerops.PubSub, "capacity:#{organization.id}")
end
# Default empty topology
default_topology = %{
nodes: [],
edges: [],
stats: %{
total_devices: 0,
added_devices: 0,
discovered_devices: 0,
total_links: 0,
high_utilization_links: 0,
overutilized_links: 0
},
last_updated: DateTime.utc_now()
}
# Schedule auto-refresh every 60 seconds
if connected?(socket) do
Process.send_after(self(), :refresh_data, 60_000)
end
{:ok,
socket
|> assign(:page_title, t("Network Weathermap"))
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:loading, true)
|> assign(:topology, default_topology)
|> assign(:active_tab, "added")
|> assign(:selected_node_detail, nil)
|> assign(:filter, "all")
|> assign(:search_query, "")
|> assign(:layout_mode, "force")
|> assign(:fullscreen, false)}
end
@impl true
def handle_params(params, _url, socket) do
tab = Map.get(params, "tab", "added")
fullscreen = Map.get(params, "fullscreen") == "true"
organization = socket.assigns.current_scope.organization
socket =
socket
|> assign(:active_tab, tab)
|> assign(:fullscreen, fullscreen)
|> load_weathermap_data(organization.id, tab)
# Handle ?node= param for deep-linking to a selected node
socket =
case Map.get(params, "node") do
nil ->
socket
node_id ->
load_node_detail(socket, node_id, organization.id)
end
{:noreply, socket}
end
@impl true
def handle_event("node_clicked", %{"node_id" => node_id} = params, socket) do
organization = socket.assigns.current_scope.organization
node_type = Map.get(params, "node_type", "managed")
socket =
socket
|> load_node_detail(node_id, organization.id)
|> then(fn s ->
if s.assigns.selected_node_detail do
push_event(s, "highlight_node", %{node_id: node_id, node_type: node_type})
else
s
end
end)
{:noreply, socket}
end
@impl true
def handle_event("close_detail_panel", _params, socket) do
{:noreply,
socket
|> assign(:selected_node_detail, nil)
|> push_event("clear_highlight", %{})}
end
@impl true
def handle_event("set_filter", %{"filter" => filter}, socket) do
{:noreply,
socket
|> assign(:filter, filter)
|> push_event("apply_filter", %{filter: filter})}
end
@impl true
def handle_event("search", %{"query" => query}, socket) do
{:noreply,
socket
|> assign(:search_query, query)
|> push_event("search_nodes", %{query: query})}
end
@impl true
def handle_event("toggle_layout", %{"mode" => mode}, socket) do
{:noreply,
socket
|> assign(:layout_mode, mode)
|> push_event("change_layout", %{mode: mode})}
end
@impl true
def handle_event("toggle_fullscreen", _params, socket) do
new_fullscreen = not socket.assigns.fullscreen
path_with_params =
if new_fullscreen do
~p"/weathermap?#{%{tab: socket.assigns.active_tab, fullscreen: true}}"
else
~p"/weathermap?#{%{tab: socket.assigns.active_tab}}"
end
{:noreply, push_navigate(socket, to: path_with_params)}
end
@impl true
def handle_info(:refresh_data, socket) do
organization = socket.assigns.current_scope.organization
socket =
load_weathermap_data(socket, organization.id, socket.assigns.active_tab)
# Schedule next refresh
Process.send_after(self(), :refresh_data, 60_000)
{:noreply, socket}
end
@impl true
def handle_info({:topology_updated, _organization_id}, socket) do
# Real-time topology update
{:noreply,
load_weathermap_data(
socket,
socket.assigns.current_scope.organization.id,
socket.assigns.active_tab
)}
end
@impl true
def handle_info({:capacity_updated, _organization_id}, socket) do
# Real-time capacity/utilization update
{:noreply,
load_weathermap_data(
socket,
socket.assigns.current_scope.organization.id,
socket.assigns.active_tab
)}
end
defp load_weathermap_data(socket, organization_id, tab) do
topology = Topology.get_topology_for_weathermap(organization_id, tab)
socket
|> assign(:topology, topology)
|> assign(:loading, false)
|> push_event("update_weathermap", %{topology: topology})
end
defp load_node_detail(socket, node_id, organization_id) do
case Topology.get_node_detail(node_id, organization_id) do
nil -> assign(socket, :selected_node_detail, nil)
detail -> assign(socket, :selected_node_detail, detail)
end
end
end

View file

@ -0,0 +1,615 @@
<div class={["h-screen flex flex-col", if(@fullscreen, do: "fixed inset-0 z-50 bg-white dark:bg-gray-900")]}>
<%= if not @fullscreen do %>
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="weathermap"
>
<.header>
<span class="flex items-center gap-2">
<.icon name="hero-signal" class="h-6 w-6 text-green-600" />
{t("Network Weathermap")}
<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/30">
{t("Live")}
</span>
</span>
<:subtitle>{t("Real-time network utilization visualization")}</:subtitle>
<:actions>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500 font-mono">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75">
</span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span>60s refresh</span>
</div>
<button
phx-click="toggle_fullscreen"
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
<.icon name="hero-arrows-pointing-out" class="h-4 w-4" />
{t("Fullscreen")}
</button>
</div>
</:actions>
</.header>
</Layouts.authenticated>
<% else %>
<!-- Fullscreen header -->
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900">
<div class="flex items-center gap-3">
<.icon name="hero-signal" class="h-6 w-6 text-green-600" />
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Network Weathermap</h1>
<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/30">
Live
</span>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500 font-mono">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75">
</span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span>60s refresh</span>
</div>
<button
phx-click="toggle_fullscreen"
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
<.icon name="hero-arrows-pointing-in" class="h-4 w-4" />
{t("Exit Fullscreen")}
</button>
</div>
</div>
<% end %>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 dark:border-white/10 px-4 bg-white dark:bg-gray-900">
<nav class="-mb-px flex space-x-8">
<.link
patch={~p"/weathermap?tab=added#{if(@fullscreen, do: "&fullscreen=true", else: "")}"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "added" do
"border-blue-500 text-blue-600 dark:text-blue-400"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
end
]}
>
{t("Added Devices")}
</.link>
<.link
patch={~p"/weathermap?tab=all#{if(@fullscreen, do: "&fullscreen=true", else: "")}"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "all" do
"border-blue-500 text-blue-600 dark:text-blue-400"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
end
]}
>
{t("All Devices")}
<span class="ml-2 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{@topology.stats.discovered_devices}
</span>
</.link>
</nav>
</div>
<%= if @loading do %>
<div class="flex-1 flex items-center justify-center bg-white dark:bg-gray-900">
<div class="text-center">
<.icon name="hero-arrow-path" class="h-12 w-12 text-gray-400 animate-spin mx-auto" />
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading network weathermap...</p>
</div>
</div>
<% else %>
<!-- Stats Bar -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-6 p-4 bg-gray-50 dark:bg-gray-800/50">
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<.icon name="hero-server" class="h-5 w-5 text-gray-400" />
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
{t("Devices")}
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{@topology.stats.total_devices}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<.icon name="hero-link" class="h-5 w-5 text-blue-400" />
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
{t("Links")}
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{@topology.stats.total_links}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-5 h-5 rounded bg-green-400"></div>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
{t("Low (0-30%)")}
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{@topology.stats.low_utilization_links || 0}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-5 h-5 rounded bg-yellow-400"></div>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
{t("Medium (30-60%)")}
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{@topology.stats.medium_utilization_links || 0}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-5 h-5 rounded bg-orange-400"></div>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
{t("High (60-80%)")}
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{@topology.stats.high_utilization_links || 0}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-5 h-5 rounded bg-red-400"></div>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
{t("Critical (80%+)")}
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{@topology.stats.overutilized_links || 0}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Filter Bar -->
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-white/10 p-3">
<div class="flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-center gap-2">
<button
phx-click="set_filter"
phx-value-filter="all"
class={[
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
if(@filter == "all",
do: "bg-blue-100 text-blue-700 dark:bg-blue-400/20 dark:text-blue-400",
else: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
)
]}
>
All Links
</button>
<button
phx-click="set_filter"
phx-value-filter="high_util"
class={[
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
if(@filter == "high_util",
do: "bg-orange-100 text-orange-700 dark:bg-orange-400/20 dark:text-orange-400",
else: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
)
]}
>
High Utilization (60%+)
<%= if (@topology.stats[:high_utilization_links] || 0) + (@topology.stats[:overutilized_links] || 0) > 0 do %>
<span class="ml-1 inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-orange-800 bg-orange-200 rounded-full dark:bg-orange-900 dark:text-orange-300">
{(@topology.stats[:high_utilization_links] || 0) + (@topology.stats[:overutilized_links] || 0)}
</span>
<% end %>
</button>
<button
phx-click="set_filter"
phx-value-filter="critical"
class={[
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
if(@filter == "critical",
do: "bg-red-100 text-red-700 dark:bg-red-400/20 dark:text-red-400",
else: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
)
]}
>
Critical (80%+)
<%= if (@topology.stats[:overutilized_links] || 0) > 0 do %>
<span class="ml-1 inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-red-800 bg-red-200 rounded-full dark:bg-red-900 dark:text-red-300">
{@topology.stats.overutilized_links}
</span>
<% end %>
</button>
</div>
<div class="flex items-center gap-3">
<div class="relative">
<.icon
name="hero-magnifying-glass"
class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"
/>
<input
type="text"
placeholder="Search devices..."
phx-keyup="search"
phx-key="Enter"
phx-debounce="300"
value={@search_query}
class="pl-8 pr-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 w-48"
/>
</div>
<%= if @topology[:has_geo] do %>
<div class="flex items-center gap-1 border-l border-gray-200 dark:border-gray-600 pl-3">
<button
phx-click="toggle_layout"
phx-value-mode="force"
class={[
"px-2 py-1 text-xs rounded transition-colors",
if(@layout_mode == "force",
do: "bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white font-medium",
else: "text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
)
]}
title="Force-directed layout"
>
<.icon name="hero-share" class="h-4 w-4" />
</button>
<button
phx-click="toggle_layout"
phx-value-mode="geo"
class={[
"px-2 py-1 text-xs rounded transition-colors",
if(@layout_mode == "geo",
do: "bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white font-medium",
else: "text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
)
]}
title="Geographic layout"
>
<.icon name="hero-map" class="h-4 w-4" />
</button>
</div>
<% end %>
</div>
</div>
</div>
<!-- Network Weathermap Container -->
<div class="flex-1 bg-white dark:bg-gray-800 relative overflow-hidden">
<div class="absolute inset-0 border-t border-gray-200 dark:border-white/10">
<div
id="cy-weathermap-container"
phx-hook="WeathermapViewer"
class="w-full h-full"
data-topology={Jason.encode!(@topology)}
>
</div>
<!-- Zoom Controls -->
<div class="absolute bottom-4 left-4 flex flex-col gap-1 z-10">
<button
id="cy-zoom-in"
type="button"
title="Zoom in"
class="flex items-center justify-center w-8 h-8 bg-white dark:bg-gray-700 border border-gray-200 dark:border-white/10 rounded shadow-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<.icon name="hero-plus" class="h-4 w-4" />
</button>
<button
id="cy-zoom-out"
type="button"
title="Zoom out"
class="flex items-center justify-center w-8 h-8 bg-white dark:bg-gray-700 border border-gray-200 dark:border-white/10 rounded shadow-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<.icon name="hero-minus" class="h-4 w-4" />
</button>
<button
id="cy-fit"
type="button"
title="Fit to screen"
class="flex items-center justify-center w-8 h-8 bg-white dark:bg-gray-700 border border-gray-200 dark:border-white/10 rounded shadow-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<.icon name="hero-arrows-pointing-out" class="h-4 w-4" />
</button>
</div>
<!-- Utilization Legend -->
<div class="absolute bottom-4 right-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-white/10 rounded-lg shadow-lg p-4 z-10">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Link Utilization</h4>
<div class="space-y-2 text-xs">
<div class="flex items-center gap-2">
<div class="w-6 h-1 rounded bg-green-500"></div>
<span class="text-gray-700 dark:text-gray-300">0-30% (Good)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-6 h-1 rounded bg-yellow-500"></div>
<span class="text-gray-700 dark:text-gray-300">30-60% (Moderate)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-6 h-1 rounded bg-orange-500"></div>
<span class="text-gray-700 dark:text-gray-300">60-80% (High)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-6 h-1 rounded bg-red-500"></div>
<span class="text-gray-700 dark:text-gray-300">80%+ (Critical)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-6 h-1 border border-gray-300 dark:border-gray-600"></div>
<span class="text-gray-500 dark:text-gray-400">No capacity data</span>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-white/10 text-xs text-gray-500 dark:text-gray-400">
Last updated: <.timestamp datetime={@topology.last_updated} timezone={@timezone} />
</div>
</div>
<!-- Node Detail Panel (slide-out) -->
<%= if @selected_node_detail do %>
<div
id="node-detail-panel"
class="absolute top-0 right-0 h-full w-80 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-white/10 shadow-lg overflow-y-auto transition-transform duration-200"
>
<div class="p-4">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Node Details")}
</h3>
<button
phx-click="close_detail_panel"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<.icon name="hero-x-mark" class="h-5 w-5" />
</button>
</div>
<!-- Device Info -->
<div class="space-y-3">
<div>
<h4 class="text-base font-medium text-gray-900 dark:text-white">
{@selected_node_detail.name}
</h4>
<%= if @selected_node_detail.type == :discovered do %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300 mt-1">
{t("Discovered")}
</span>
<% end %>
</div>
<%= if @selected_node_detail.ip_address do %>
<div class="flex items-center text-sm">
<.icon name="hero-globe-alt" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" />
<span class="text-gray-700 dark:text-gray-300">
{@selected_node_detail.ip_address}
</span>
</div>
<% end %>
<%= if @selected_node_detail.site_name do %>
<div class="flex items-center text-sm">
<.icon name="hero-map-pin" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" />
<span class="text-gray-700 dark:text-gray-300">
{@selected_node_detail.site_name}
</span>
</div>
<% end %>
<%= if @selected_node_detail.manufacturer do %>
<div class="flex items-center text-sm">
<.icon
name="hero-building-office"
class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0"
/>
<span class="text-gray-700 dark:text-gray-300">
{@selected_node_detail.manufacturer}
</span>
</div>
<% end %>
<div class="flex items-center text-sm">
<.icon name="hero-tag" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" />
<span class="text-gray-700 dark:text-gray-300 capitalize">
{@selected_node_detail.role}
</span>
</div>
<%= if @selected_node_detail.status && @selected_node_detail.status != :unknown do %>
<div class="flex items-center text-sm">
<span class={[
"inline-block h-2 w-2 rounded-full mr-2",
if(@selected_node_detail.status == :up,
do: "bg-green-400",
else: "bg-red-400"
)
]}>
</span>
<span class="text-gray-700 dark:text-gray-300 capitalize">
{@selected_node_detail.status}
</span>
</div>
<% end %>
</div>
<!-- Utilization Stats (for devices with bandwidth data) -->
<%= if @selected_node_detail[:utilization_stats] do %>
<div class="mt-4 pt-3 border-t border-gray-200 dark:border-white/10">
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Interface Utilization
</h4>
<div class="space-y-2">
<%= for stat <- @selected_node_detail.utilization_stats do %>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-md p-3">
<div class="flex items-center justify-between mb-1">
<div class="text-xs font-medium text-gray-900 dark:text-white">
{stat.interface_name}
</div>
<div class={[
"text-xs font-semibold",
case stat.utilization_level do
:low -> "text-green-600 dark:text-green-400"
:medium -> "text-yellow-600 dark:text-yellow-400"
:high -> "text-orange-600 dark:text-orange-400"
:critical -> "text-red-600 dark:text-red-400"
_ -> "text-gray-500 dark:text-gray-400"
end
]}>
{stat.utilization_pct}%
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{stat.throughput_text} / {stat.capacity_text}
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<!-- Connections -->
<%= if length(@selected_node_detail.connections) > 0 do %>
<div class="mt-5 pt-4 border-t border-gray-200 dark:border-white/10">
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
{t("Connections")} ({length(@selected_node_detail.connections)})
</h4>
<div class="space-y-2">
<%= for conn <- @selected_node_detail.connections do %>
<div class="flex items-center justify-between py-1.5 px-2 rounded bg-gray-50 dark:bg-gray-700/50 text-sm">
<div class="flex-1 min-w-0">
<p class="text-gray-900 dark:text-white truncate font-medium text-xs">
{conn.device_name}
</p>
<%= if conn.interface do %>
<p class="text-gray-500 dark:text-gray-400 text-xs">
{conn.interface}
</p>
<% end %>
<%= if conn[:utilization_text] do %>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">
{conn.utilization_text}
</p>
<% end %>
</div>
<div class="flex items-center space-x-2 ml-2 flex-shrink-0">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase">
{conn.link_type}
</span>
<span class={[
"text-xs font-medium",
cond do
conn.confidence > 0.9 -> "text-green-600 dark:text-green-400"
conn.confidence >= 0.5 -> "text-yellow-600 dark:text-yellow-400"
true -> "text-gray-500 dark:text-gray-400"
end
]}>
{trunc(conn.confidence * 100)}%
</span>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<!-- View Device Link (managed only) -->
<%= if @selected_node_detail.type == :managed do %>
<div class="mt-4">
<.link
navigate={~p"/devices/#{@selected_node_detail.id}"}
class="flex items-center justify-center w-full px-3 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10 rounded-md hover:bg-blue-100 dark:hover:bg-blue-400/20"
>
<.icon name="hero-arrow-top-right-on-square" class="h-4 w-4 mr-1.5" />
{t("View Device")}
</.link>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<!-- Empty State -->
<%= if @topology.stats.total_devices == 0 do %>
<div class="flex-1 flex items-center justify-center">
<div class="text-center py-16">
<.icon name="hero-signal" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
{t("No network data available")}
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{t("Add devices with SNMP enabled and configure interface capacities to view utilization.")}
</p>
<div class="mt-6">
<.button navigate={~p"/devices/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> Add Your First Device
</.button>
</div>
</div>
</div>
<% end %>
<% end %>
</div>

View file

@ -490,6 +490,7 @@ defmodule ToweropsWeb.Router do
# Network map routes
live "/network-map", NetworkMapLive, :index
live "/weathermap", WeathermapLive, :index
live "/sites-map", MapLive.Index, :index
end
end