improve network map topology visualization

- fix bidirectional link queries to find links where device is source or target
- merge A→B and B→A edges into single link with combined confidence
- include interface speed in edge data for variable edge width
- infer discovered node roles from LLDP/CDP capabilities (router, switch, wireless, phone)
- store remote_capabilities in link metadata during evidence processing
- replace Cytoscape destroy-and-recreate with element diffing to preserve zoom/pan/positions
- add node detail slide-out panel with device info, connections, and confidence
- support ?node= URL param for deep-linking to selected nodes
- add site-based compound node grouping in Cytoscape
This commit is contained in:
Graham McIntire 2026-03-12 10:59:20 -05:00
parent 72f81c572d
commit 80304329bd
No known key found for this signature in database
6 changed files with 1139 additions and 51 deletions

View file

@ -650,20 +650,22 @@ const NetworkMap = {
// Build Cytoscape elements from topology data
const elements: any[] = []
// Add nodes
// Add nodes (site compound nodes first, then devices)
topology.nodes?.forEach((node: any) => {
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
}
if (node.parent) data.parent = node.parent
elements.push({
data: {
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
},
classes: node.discovered ? 'discovered' : node.status
data: data,
classes: node.type === 'site' ? 'site-group' : (node.discovered ? 'discovered' : node.status)
})
})
@ -683,11 +685,20 @@ const NetworkMap = {
target_interface: edge.target_interface,
link_type: edge.link_type,
confidence: edge.confidence ?? 0.5,
if_speed: edge.if_speed,
label: label
}
})
})
// Map interface speed to edge width
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
}
// Initialize Cytoscape
this.cy = cytoscape({
container: this.el,
@ -767,10 +778,40 @@ const NetworkMap = {
'border-color': '#fbbf24'
}
},
{
selector: 'node.highlighted',
style: {
'border-width': 4,
'border-color': '#3b82f6',
'overlay-color': '#3b82f6',
'overlay-opacity': 0.1
}
},
{
selector: '$node > node', // compound parent selector
style: {
'background-color': isDark ? '#1e293b' : '#f8fafc',
'background-opacity': 0.6,
'border-width': 1,
'border-color': isDark ? '#334155' : '#e2e8f0',
'border-style': 'solid',
'shape': 'round-rectangle',
'padding': '20px',
'text-valign': 'top',
'text-halign': 'center',
'text-margin-y': -5,
'label': 'data(label)',
'font-size': '12px',
'font-weight': '600',
'color': isDark ? '#94a3b8' : '#64748b'
} as any
},
{
selector: 'edge',
style: {
'width': 2,
'width': function(ele: any) {
return getEdgeWidth(ele.data('if_speed'))
},
'line-color': function(ele: any) {
return getEdgeStyle(ele.data('confidence')).color
},
@ -831,16 +872,31 @@ const NetworkMap = {
const node = evt.target
const nodeData = node.data()
// Push event to LiveView with node info
this.pushEvent("node_clicked", { node_id: nodeData.id })
// Push event to LiveView to open detail panel
this.pushEvent("node_clicked", {
node_id: nodeData.id,
node_type: nodeData.discovered ? 'discovered' : 'managed'
})
})
// Show tooltip or navigate
if (!nodeData.discovered) {
// Navigate to device detail page
window.location.href = `/devices/${nodeData.id}`
// Handle highlight_node event from LiveView
this.handleEvent("highlight_node", (payload: any) => {
if (!this.cy) return
// Remove highlight from all nodes
this.cy.nodes().removeClass('highlighted')
// Add highlight to selected node
const node = this.cy.getElementById(payload.node_id)
if (node.length > 0) {
node.addClass('highlighted')
}
})
// Handle clear_highlight event from LiveView
this.handleEvent("clear_highlight", () => {
if (!this.cy) return
this.cy.nodes().removeClass('highlighted')
})
// Add hover tooltips
this.cy.on('mouseover', 'node', (evt: any) => {
const node = evt.target
@ -885,7 +941,7 @@ const NetworkMap = {
const edgeStyle = getEdgeStyle(data.confidence ?? 0.5)
edge.style({
'line-color': edgeStyle.color,
'width': 2,
'width': getEdgeWidth(data.if_speed),
'label': data.label || ''
})
})
@ -894,9 +950,121 @@ const NetworkMap = {
updateTopology(this: any, topology: any) {
if (!this.cy) return
// Rebuild the graph with new data
this.cy.destroy()
this.initializeCytoscape(topology)
// Build incoming element maps
const incomingNodes = new Map<string, any>()
topology.nodes?.forEach((node: any) => {
incomingNodes.set(node.id, node)
})
const incomingEdges = new Map<string, any>()
topology.edges?.forEach((edge: any) => {
incomingEdges.set(edge.id, edge)
})
// Track which nodes already exist
const existingNodeIds = new Set<string>()
const existingEdgeIds = new Set<string>()
// Update or remove existing nodes
this.cy.nodes().forEach((node: any) => {
const id = node.id()
if (incomingNodes.has(id)) {
// Update data
const incoming = incomingNodes.get(id)
node.data('label', incoming.label)
node.data('type', incoming.type)
node.data('status', incoming.status)
node.data('discovered', incoming.discovered)
node.data('ip_address', incoming.ip_address)
node.data('site_name', incoming.site_name)
node.data('manufacturer', incoming.manufacturer)
if (incoming.parent) node.data('parent', incoming.parent)
existingNodeIds.add(id)
} else {
node.remove()
}
})
// Update or remove existing edges
this.cy.edges().forEach((edge: any) => {
const id = edge.id()
if (incomingEdges.has(id)) {
const incoming = incomingEdges.get(id)
edge.data('confidence', incoming.confidence ?? 0.5)
edge.data('link_type', incoming.link_type)
edge.data('source_interface', incoming.source_interface)
edge.data('target_interface', incoming.target_interface)
const sourceLabel = incoming.source_interface || ''
const targetLabel = incoming.target_interface || ''
edge.data('label', [sourceLabel, targetLabel].filter(Boolean).join(' \u2194 '))
existingEdgeIds.add(id)
} else {
edge.remove()
}
})
// Add new nodes
const newElements: any[] = []
incomingNodes.forEach((node, id) => {
if (!existingNodeIds.has(id)) {
newElements.push({
group: 'nodes',
data: {
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,
parent: node.parent
},
classes: node.discovered ? 'discovered' : node.status
})
}
})
// Add new edges
incomingEdges.forEach((edge, id) => {
if (!existingEdgeIds.has(id)) {
const sourceLabel = edge.source_interface || ''
const targetLabel = edge.target_interface || ''
const label = [sourceLabel, targetLabel].filter(Boolean).join(' \u2194 ')
newElements.push({
group: 'edges',
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,
label: label
}
})
}
})
// Only add and layout new elements (preserves existing positions)
if (newElements.length > 0) {
const added = this.cy.add(newElements)
// Run layout only on new elements
if (added.nonempty()) {
added.layout({
name: 'cose',
animate: true,
animationDuration: 300,
fit: false,
randomize: false,
nodeRepulsion: 30000,
idealEdgeLength: 250
}).run()
}
}
}
}

View file

@ -257,6 +257,24 @@ defmodule Towerops.Topology do
@doc "Merge two independent confidence scores: 1 - (1-a)(1-b)"
def merge_confidence(a, b), do: 1.0 - (1.0 - a) * (1.0 - b)
@doc """
Infer the type of a discovered device from LLDP/CDP capability data.
Returns an atom (:router, :switch, :wireless, :phone, :unknown).
"""
def infer_role_from_capabilities(nil), do: :unknown
def infer_role_from_capabilities([]), do: :unknown
def infer_role_from_capabilities(capabilities) when is_list(capabilities) do
cond do
"router" in capabilities -> :router
"bridge" in capabilities and "wlan-ap" in capabilities -> :wireless
"wlan-ap" in capabilities -> :wireless
"bridge" in capabilities -> :switch
"telephone" in capabilities -> :phone
true -> :unknown
end
end
@doc """
Create or update a device link. Keyed on source_device_id + source_interface_id +
discovered_remote_mac. If existing, merges confidence and appends evidence.
@ -408,16 +426,41 @@ defmodule Towerops.Topology do
managed_nodes = Enum.map(devices, &device_to_node/1)
discovered_nodes = build_discovered_nodes(links, tab)
all_nodes = managed_nodes ++ discovered_nodes
# 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
}
end)
all_nodes = site_nodes ++ managed_nodes ++ discovered_nodes
node_ids = MapSet.new(Enum.map(all_nodes, & &1.id))
edges = build_edges_from_links(links, node_ids)
edges =
links
|> build_edges_from_links(node_ids)
|> merge_bidirectional_edges()
%{
nodes: all_nodes,
edges: edges,
stats: %{
total_devices: length(all_nodes),
total_devices: length(managed_nodes) + length(discovered_nodes),
added_devices: length(managed_nodes),
discovered_devices: length(discovered_nodes),
total_links: length(edges)
@ -426,6 +469,124 @@ defmodule Towerops.Topology do
}
end
@doc """
Get detail information for a node (managed or discovered) for the detail panel.
For managed devices: returns device with site, connections.
For discovered nodes (id starts with "discovered_"): returns info from link metadata.
Returns nil if node not found or not in organization.
"""
def get_node_detail("discovered_" <> _suffix = node_id, organization_id) do
# Find the link that created this discovered node
device_ids =
Repo.all(
from(d in Device,
where: d.organization_id == ^organization_id,
select: d.id
)
)
links =
Repo.all(
from(l in DeviceLink,
where: l.source_device_id in ^device_ids and is_nil(l.target_device_id),
preload: [:source_device, :source_interface, :target_interface]
)
)
# Find the link matching this discovered node ID
link =
Enum.find(links, fn l ->
discovered_node_id(l) == node_id
end)
case link do
nil ->
nil
link ->
capabilities = get_in(link.metadata, ["remote_capabilities"])
%{
id: node_id,
name: link.discovered_remote_name || link.discovered_remote_ip || link.discovered_remote_mac,
ip_address: link.discovered_remote_ip,
mac_address: link.discovered_remote_mac,
type: :discovered,
role: infer_role_from_capabilities(capabilities),
status: :unknown,
site_name: nil,
manufacturer: nil,
connections: [
%{
device_id: link.source_device_id,
device_name: link.source_device.name,
interface: if(link.source_interface, do: link.source_interface.if_name),
confidence: link.confidence,
link_type: link.link_type
}
]
}
end
end
def get_node_detail(device_id, organization_id) do
device =
Repo.one(
from(d in Device,
where: d.id == ^device_id and d.organization_id == ^organization_id,
left_join: s in assoc(d, :site),
left_join: sd in assoc(d, :snmp_device),
preload: [site: s, snmp_device: sd]
)
)
case device do
nil ->
nil
device ->
connections =
device_id
|> list_connected_devices()
|> Enum.map(&link_to_connection(&1, device_id))
%{
id: device.id,
name: device.name,
ip_address: device.ip_address,
type: :managed,
role: role_to_type_atom(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
}
end
end
defp link_to_connection(link, device_id) do
{connected_device, interface} =
if link.source_device_id == device_id do
{link.target_device, link.target_interface}
else
{link.source_device, link.source_interface}
end
%{
device_id: if(connected_device, do: connected_device.id),
device_name: connected_device_name(connected_device, link),
interface: if(interface, do: interface.if_name),
confidence: link.confidence,
link_type: link.link_type
}
end
defp connected_device_name(device, _link) when not is_nil(device), do: device.name
defp connected_device_name(nil, link),
do: link.discovered_remote_name || link.discovered_remote_ip || link.discovered_remote_mac
@doc "List all device links where this device is the source, ordered by confidence."
def list_device_links(device_id) do
Repo.all(
@ -467,7 +628,7 @@ defmodule Towerops.Topology do
defp list_links_for_devices(device_ids) do
Repo.all(
from(l in DeviceLink,
where: l.source_device_id in ^device_ids,
where: l.source_device_id in ^device_ids or l.target_device_id in ^device_ids,
preload: [:source_interface, :target_interface]
)
)
@ -484,7 +645,8 @@ defmodule Towerops.Topology do
site_name: if(device.site, do: device.site.name),
ip_address: device.ip_address,
discovered: false,
manufacturer: if(device.snmp_device, do: device.snmp_device.manufacturer)
manufacturer: if(device.snmp_device, do: device.snmp_device.manufacturer),
parent: if(device.site_id, do: "site_#{device.site_id}")
}
end
@ -493,10 +655,13 @@ defmodule Towerops.Topology do
|> Enum.filter(&is_nil(&1.target_device_id))
|> Enum.uniq_by(&discovered_node_id/1)
|> Enum.map(fn link ->
capabilities = get_in(link.metadata, ["remote_capabilities"])
inferred_type = infer_role_from_capabilities(capabilities)
%{
id: discovered_node_id(link),
label: link.discovered_remote_name || link.discovered_remote_ip || link.discovered_remote_mac,
type: :unknown,
type: inferred_type,
device_role: nil,
status: :unknown,
site_id: nil,
@ -511,6 +676,55 @@ defmodule Towerops.Topology do
defp build_discovered_nodes(_links, _tab), do: []
defp merge_bidirectional_edges(edges) do
edges
|> Enum.group_by(fn edge ->
{min(edge.source, edge.target), max(edge.source, edge.target)}
end)
|> Enum.map(fn {_key, group} -> merge_edge_group(group) end)
end
defp merge_edge_group([single]), do: single
defp merge_edge_group([first | rest] = group) do
merged_confidence =
Enum.reduce(rest, first.confidence, fn edge, acc ->
merge_confidence(acc, edge.confidence)
end)
all_interfaces =
group
|> Enum.flat_map(&[&1.source_interface, &1.target_interface])
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
best_link_type =
group
|> Enum.map(& &1.link_type)
|> Enum.min_by(&link_type_priority/1)
if_speed =
group
|> Enum.map(&Map.get(&1, :if_speed))
|> Enum.reject(&is_nil/1)
|> Enum.max(fn -> nil end)
%{
first
| confidence: merged_confidence,
link_type: best_link_type,
source_interface: Enum.at(all_interfaces, 0),
target_interface: Enum.at(all_interfaces, 1),
if_speed: if_speed
}
end
defp link_type_priority("lldp"), do: 0
defp link_type_priority("cdp"), do: 1
defp link_type_priority("mac_match"), do: 2
defp link_type_priority("arp_inference"), do: 3
defp link_type_priority(_), do: 4
defp build_edges_from_links(links, node_ids) do
links
|> Enum.map(fn link ->
@ -527,7 +741,8 @@ defmodule Towerops.Topology do
target_interface: if(link.target_interface, do: link.target_interface.if_name),
link_type: link.link_type,
confidence: link.confidence,
metadata: link.metadata
metadata: link.metadata,
if_speed: if(link.source_interface, do: link.source_interface.if_speed)
}
end)
|> Enum.filter(fn edge ->
@ -571,7 +786,7 @@ defmodule Towerops.Topology do
discovered_remote_mac: remote_identity.remote_mac,
discovered_remote_ip: remote_identity.remote_ip,
discovered_remote_name: remote_identity.remote_name,
metadata: %{},
metadata: build_link_metadata(evidence_list),
last_confirmed_at: now,
evidence:
Enum.map(evidence_list, fn ev ->
@ -585,6 +800,19 @@ defmodule Towerops.Topology do
end
end
defp build_link_metadata(evidence_list) do
capabilities =
evidence_list
|> Enum.flat_map(fn ev -> ev[:remote_capabilities] || [] end)
|> Enum.uniq()
if Enum.empty?(capabilities) do
%{}
else
%{"remote_capabilities" => capabilities}
end
end
defp group_evidence_by_remote(evidence_list) do
Enum.group_by(evidence_list, &evidence_group_key/1)
end

View file

@ -33,7 +33,8 @@ defmodule ToweropsWeb.NetworkMapLive do
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:loading, true)
|> assign(:topology, default_topology)
|> assign(:active_tab, "added")}
|> assign(:active_tab, "added")
|> assign(:selected_node_detail, nil)}
end
@impl true
@ -41,10 +42,22 @@ defmodule ToweropsWeb.NetworkMapLive do
tab = Map.get(params, "tab", "added")
organization = socket.assigns.current_scope.organization
{:noreply,
socket
|> assign(:active_tab, tab)
|> load_topology_data(organization.id, tab)}
socket =
socket
|> assign(:active_tab, tab)
|> load_topology_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
@ -59,15 +72,41 @@ defmodule ToweropsWeb.NetworkMapLive do
end
@impl true
def handle_event("node_clicked", %{"node_id" => node_id}, socket) do
# Handle node click - could navigate to device detail page
{:noreply, assign(socket, :selected_node, node_id)}
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_info({:topology_updated, _organization_id}, socket) do
# Real-time topology update
{:noreply, load_topology_data(socket, socket.assigns.current_scope.organization.id, socket.assigns.active_tab)}
{:noreply,
load_topology_data(
socket,
socket.assigns.current_scope.organization.id,
socket.assigns.active_tab
)}
end
defp load_topology_data(socket, organization_id, tab) do
@ -78,4 +117,11 @@ defmodule ToweropsWeb.NetworkMapLive do
|> assign(:loading, false)
|> push_event("update_topology", %{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

@ -165,14 +165,159 @@
</div>
</div>
<!-- Cytoscape Container -->
<div
id="cy-container"
phx-hook="NetworkMap"
class="w-full"
style="height: 600px;"
data-topology={Jason.encode!(@topology)}
>
<!-- Cytoscape Container with Detail Panel -->
<div class="relative">
<div
id="cy-container"
phx-hook="NetworkMap"
class="w-full"
style="height: 600px;"
data-topology={Jason.encode!(@topology)}
>
</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>
<!-- 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 %>
</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>
<!-- Legend -->

View file

@ -822,9 +822,10 @@ defmodule Towerops.TopologyTest do
%{router: router, switch: switch, organization: org} do
result = Topology.get_topology_for_map(org.id, "added")
assert length(result.nodes) == 2
assert Enum.all?(result.nodes, &(!&1.discovered))
node_ids = Enum.map(result.nodes, & &1.id)
device_nodes = Enum.reject(result.nodes, &(&1.type == :site))
assert length(device_nodes) == 2
assert Enum.all?(device_nodes, &(!&1.discovered))
node_ids = Enum.map(device_nodes, & &1.id)
assert router.id in node_ids
assert switch.id in node_ids
end
@ -912,6 +913,311 @@ defmodule Towerops.TopologyTest do
end
end
describe "list_links_for_devices/1 (bidirectional)" do
test "returns links where device is target (not just source)",
%{router: router, switch: switch, organization: org} do
now = DateTime.utc_now()
# Create a switch SNMP device and interface
switch_snmp =
%Device{}
|> Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
|> Repo.insert!()
switch_iface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: switch_snmp.id,
if_index: 1,
if_name: "ge-0/0/1",
if_phys_address: "bb:cc:dd:00:11:22"
})
|> Repo.insert!()
# Create link FROM switch TO router (router is the target)
{:ok, _} =
Topology.upsert_link(%{
source_device_id: switch.id,
target_device_id: router.id,
source_interface_id: switch_iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "aa:bb:cc:00:11:22",
last_confirmed_at: now,
evidence: []
})
# Both router and switch should be in the device_ids
result = Topology.get_topology_for_map(org.id, "added")
# The link should appear because both source and target are in our org's devices
assert length(result.edges) == 1
end
end
describe "merge_bidirectional_edges/1" do
test "merges A→B and B→A into one edge with combined confidence",
%{router: router, switch: switch, router_iface: iface, organization: org} do
now = DateTime.utc_now()
switch_snmp =
%Device{}
|> Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
|> Repo.insert!()
switch_iface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: switch_snmp.id,
if_index: 1,
if_name: "ge-0/0/1",
if_phys_address: "bb:cc:dd:00:11:22"
})
|> Repo.insert!()
# Create A→B link (router → switch)
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: switch.id,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "bb:cc:dd:00:11:22",
last_confirmed_at: now,
evidence: []
})
# Create B→A link (switch → router)
{:ok, _} =
Topology.upsert_link(%{
source_device_id: switch.id,
target_device_id: router.id,
source_interface_id: switch_iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "aa:bb:cc:00:11:22",
last_confirmed_at: now,
evidence: []
})
result = Topology.get_topology_for_map(org.id, "added")
# Should have exactly 1 merged edge, not 2
assert length(result.edges) == 1
[edge] = result.edges
# Both devices should be represented
assert MapSet.new([edge.source, edge.target]) == MapSet.new([router.id, switch.id])
# Merged confidence: 1 - (1-0.95)(1-0.95) = 0.9975
assert_in_delta edge.confidence, 0.9975, 0.001
end
test "preserves single-direction links unchanged",
%{router: router, switch: switch, router_iface: iface, organization: org} do
now = DateTime.utc_now()
# Create only A→B link (router → switch)
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: switch.id,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "bb:cc:dd:00:11:22",
last_confirmed_at: now,
evidence: []
})
result = Topology.get_topology_for_map(org.id, "added")
assert length(result.edges) == 1
[edge] = result.edges
assert_in_delta edge.confidence, 0.95, 0.001
end
end
describe "edge data includes if_speed" do
test "edge data includes if_speed when available",
%{router: router, switch: switch, router_iface: iface, organization: org} do
# Update router interface with speed
iface
|> Interface.changeset(%{if_speed: 1_000_000_000})
|> Repo.update!()
now = DateTime.utc_now()
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: switch.id,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "bb:cc:dd:00:11:22",
last_confirmed_at: now,
evidence: []
})
result = Topology.get_topology_for_map(org.id, "added")
assert length(result.edges) == 1
[edge] = result.edges
assert edge.if_speed == 1_000_000_000
end
end
describe "site-based visual grouping" do
test "topology data includes site compound nodes",
%{organization: org, site: site} do
result = Topology.get_topology_for_map(org.id, "added")
site_nodes = Enum.filter(result.nodes, &(&1.type == :site))
assert length(site_nodes) == 1
[site_node] = site_nodes
assert site_node.id == "site_#{site.id}"
assert site_node.label == site.name
end
test "device nodes have parent field set to site ID",
%{router: router, organization: org, site: site} do
result = Topology.get_topology_for_map(org.id, "added")
router_node = Enum.find(result.nodes, &(&1.id == router.id))
assert router_node.parent == "site_#{site.id}"
end
test "devices without a site have no parent",
%{organization: org} do
{:ok, orphan} =
Towerops.Devices.create_device(
%{
name: "Orphan-Device",
ip_address: "10.0.0.99",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: nil,
organization_id: org.id
},
bypass_limits: true
)
result = Topology.get_topology_for_map(org.id, "added")
orphan_node = Enum.find(result.nodes, &(&1.id == orphan.id))
assert orphan_node.parent == nil
end
end
describe "infer_role_from_capabilities/1" do
test "returns :router for router capability" do
assert Topology.infer_role_from_capabilities(["router"]) == :router
end
test "returns :wireless for WLAN AP capability" do
assert Topology.infer_role_from_capabilities(["wlan-ap"]) == :wireless
end
test "returns :wireless for bridge + wlan-ap" do
assert Topology.infer_role_from_capabilities(["bridge", "wlan-ap"]) == :wireless
end
test "returns :switch for bridge-only" do
assert Topology.infer_role_from_capabilities(["bridge"]) == :switch
end
test "returns :phone for telephone capability" do
assert Topology.infer_role_from_capabilities(["telephone"]) == :phone
end
test "returns :unknown for empty list" do
assert Topology.infer_role_from_capabilities([]) == :unknown
end
test "returns :unknown for nil" do
assert Topology.infer_role_from_capabilities(nil) == :unknown
end
end
describe "build_discovered_nodes role inference" do
test "uses capabilities from link metadata to set node type",
%{router: router, router_iface: iface, organization: org} do
now = DateTime.utc_now()
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: nil,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "ff:ff:ff:00:00:01",
discovered_remote_name: "remote-router",
discovered_remote_ip: "10.99.99.1",
metadata: %{"remote_capabilities" => ["router"]},
last_confirmed_at: now,
evidence: []
})
result = Topology.get_topology_for_map(org.id, "all")
discovered = Enum.filter(result.nodes, & &1.discovered)
assert length(discovered) == 1
[disc] = discovered
assert disc.type == :router
end
test "discovered node with bridge capability shows as switch",
%{router: router, router_iface: iface, organization: org} do
now = DateTime.utc_now()
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: nil,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "ff:ff:ff:00:00:02",
discovered_remote_name: "remote-switch",
metadata: %{"remote_capabilities" => ["bridge"]},
last_confirmed_at: now,
evidence: []
})
result = Topology.get_topology_for_map(org.id, "all")
discovered = Enum.filter(result.nodes, & &1.discovered)
assert length(discovered) == 1
[disc] = discovered
assert disc.type == :switch
end
test "discovered node with no capabilities shows as unknown",
%{router: router, router_iface: iface, organization: org} do
now = DateTime.utc_now()
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: nil,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "ff:ff:ff:00:00:03",
discovered_remote_name: "mystery-box",
last_confirmed_at: now,
evidence: []
})
result = Topology.get_topology_for_map(org.id, "all")
discovered = Enum.filter(result.nodes, & &1.discovered)
assert length(discovered) == 1
[disc] = discovered
assert disc.type == :unknown
end
end
describe "collect_arp_evidence/2" do
test "returns evidence for ARP entries matching known devices", %{
organization: org,
@ -985,6 +1291,79 @@ defmodule Towerops.TopologyTest do
end
end
describe "get_node_detail/2" do
test "returns device info for managed device",
%{router: router, organization: org} do
result = Topology.get_node_detail(router.id, org.id)
assert result
assert result.id == router.id
assert result.name == "Core-Router"
assert result.ip_address == "10.0.0.1"
assert result.type == :managed
assert is_list(result.connections)
end
test "returns connections list for managed device",
%{router: router, switch: switch, router_iface: iface, organization: org} do
now = DateTime.utc_now()
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: switch.id,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "bb:cc:dd:00:11:22",
last_confirmed_at: now,
evidence: []
})
result = Topology.get_node_detail(router.id, org.id)
assert length(result.connections) == 1
[conn] = result.connections
assert conn.device_name == "Main-Switch"
assert_in_delta conn.confidence, 0.95, 0.01
end
test "returns discovered node info from link metadata",
%{router: router, router_iface: iface, organization: org} do
now = DateTime.utc_now()
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: nil,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "ff:ff:ff:00:00:01",
discovered_remote_name: "remote-switch",
discovered_remote_ip: "10.99.99.1",
metadata: %{"remote_capabilities" => ["bridge"]},
last_confirmed_at: now,
evidence: []
})
node_id = "discovered_ff:ff:ff:00:00:01"
result = Topology.get_node_detail(node_id, org.id)
assert result
assert result.type == :discovered
assert result.name == "remote-switch"
assert result.ip_address == "10.99.99.1"
assert result.role == :switch
end
test "scoped to organization",
%{router: _router, organization: org} do
# Non-existent device ID
result = Topology.get_node_detail(Ecto.UUID.generate(), org.id)
assert result == nil
end
end
describe "list_connected_devices/1" do
test "returns managed and discovered links for a device",
%{router: router, switch: switch, router_iface: iface} do

View file

@ -94,6 +94,128 @@ defmodule ToweropsWeb.NetworkMapLiveTest do
end
end
describe "topology update events" do
test "pushes update_topology event on mount", %{conn: conn, organization: organization} do
{:ok, site} = Towerops.Sites.create_site(%{name: "Test Site", organization_id: organization.id})
{:ok, _device} =
Towerops.Devices.create_device(
%{
name: "Test-Router",
ip_address: "10.0.0.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
},
bypass_limits: true
)
{:ok, view, _html} = live(conn, ~p"/network-map")
# Verify the topology data is loaded (not empty)
topology = view |> element("#cy-container") |> render()
assert topology =~ "data-topology"
end
test "refreshes topology on PubSub message", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/network-map")
# Broadcast a topology update
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"topology:#{organization.id}",
{:topology_updated, organization.id}
)
# Give LiveView time to process
_ = render(view)
# Verify it still renders without error
html = render(view)
assert html =~ "Topology Visualization"
end
end
describe "node detail panel" do
setup %{organization: organization} do
{:ok, site} = Towerops.Sites.create_site(%{name: "Test Site", organization_id: organization.id})
{:ok, device} =
Towerops.Devices.create_device(
%{
name: "Test-Router",
ip_address: "10.0.0.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
},
bypass_limits: true
)
%{site: site, device: device}
end
test "node_clicked event assigns selected node detail", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/network-map")
html = render_click(view, "node_clicked", %{"node_id" => device.id, "node_type" => "managed"})
assert html =~ "Test-Router"
assert html =~ "10.0.0.1"
end
test "close button clears selection", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/network-map")
# Open panel
render_click(view, "node_clicked", %{"node_id" => device.id, "node_type" => "managed"})
# Close panel
html = render_click(view, "close_detail_panel")
refute html =~ "Node Details"
end
test "?node=<id> param opens detail panel on mount", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/network-map?node=#{device.id}")
assert html =~ "Test-Router"
assert html =~ "10.0.0.1"
end
end
describe "site grouping" do
test "network map renders site groupings in topology data", %{conn: conn, organization: organization} do
{:ok, site} = Towerops.Sites.create_site(%{name: "Main Office", organization_id: organization.id})
{:ok, _device} =
Towerops.Devices.create_device(
%{
name: "Office-Router",
ip_address: "10.0.0.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
},
bypass_limits: true
)
{:ok, _view, html} = live(conn, ~p"/network-map")
# The topology data should include site info in JSON
assert html =~ "Main Office"
end
end
describe "unauthenticated access" do
test "requires authentication" do
conn = build_conn()