diff --git a/assets/js/app.ts b/assets/js/app.ts index 6db33162..db1fab90 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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() + topology.nodes?.forEach((node: any) => { + incomingNodes.set(node.id, node) + }) + + const incomingEdges = new Map() + topology.edges?.forEach((edge: any) => { + incomingEdges.set(edge.id, edge) + }) + + // Track which nodes already exist + const existingNodeIds = new Set() + const existingEdgeIds = new Set() + + // 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() + } + } } } diff --git a/lib/towerops/topology.ex b/lib/towerops/topology.ex index 28a7184a..666e68b4 100644 --- a/lib/towerops/topology.ex +++ b/lib/towerops/topology.ex @@ -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 diff --git a/lib/towerops_web/live/network_map_live.ex b/lib/towerops_web/live/network_map_live.ex index a9d475d7..12ce01b3 100644 --- a/lib/towerops_web/live/network_map_live.ex +++ b/lib/towerops_web/live/network_map_live.ex @@ -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 diff --git a/lib/towerops_web/live/network_map_live.html.heex b/lib/towerops_web/live/network_map_live.html.heex index 08fc7e06..31cca935 100644 --- a/lib/towerops_web/live/network_map_live.html.heex +++ b/lib/towerops_web/live/network_map_live.html.heex @@ -165,14 +165,159 @@ - -
+ +
+
+
+ + + <%= if @selected_node_detail do %> +
+
+ +
+

+ {t("Node Details")} +

+ +
+ + +
+
+

+ {@selected_node_detail.name} +

+ <%= if @selected_node_detail.type == :discovered do %> + + {t("Discovered")} + + <% end %> +
+ + <%= if @selected_node_detail.ip_address do %> +
+ <.icon name="hero-globe-alt" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" /> + + {@selected_node_detail.ip_address} + +
+ <% end %> + + <%= if @selected_node_detail.site_name do %> +
+ <.icon name="hero-map-pin" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" /> + + {@selected_node_detail.site_name} + +
+ <% end %> + + <%= if @selected_node_detail.manufacturer do %> +
+ <.icon + name="hero-building-office" + class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" + /> + + {@selected_node_detail.manufacturer} + +
+ <% end %> + +
+ <.icon name="hero-tag" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" /> + + {@selected_node_detail.role} + +
+ + <%= if @selected_node_detail.status && @selected_node_detail.status != :unknown do %> +
+ + + + {@selected_node_detail.status} + +
+ <% end %> +
+ + + <%= if length(@selected_node_detail.connections) > 0 do %> +
+

+ {t("Connections")} ({length(@selected_node_detail.connections)}) +

+
+ <%= for conn <- @selected_node_detail.connections do %> +
+
+

+ {conn.device_name} +

+ <%= if conn.interface do %> +

+ {conn.interface} +

+ <% end %> +
+
+ + {conn.link_type} + + 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)}% + +
+
+ <% end %> +
+
+ <% end %> + + + <%= if @selected_node_detail.type == :managed do %> +
+ <.link + navigate={~p"/devices/#{@selected_node_detail.id}"} + class="flex items-center justify-center w-full px-3 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10 rounded-md hover:bg-blue-100 dark:hover:bg-blue-400/20" + > + <.icon name="hero-arrow-top-right-on-square" class="h-4 w-4 mr-1.5" /> + {t("View Device")} + +
+ <% end %> +
+
+ <% end %>
diff --git a/test/towerops/topology_test.exs b/test/towerops/topology_test.exs index 7015331c..ca6138a7 100644 --- a/test/towerops/topology_test.exs +++ b/test/towerops/topology_test.exs @@ -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 diff --git a/test/towerops_web/live/network_map_live_test.exs b/test/towerops_web/live/network_map_live_test.exs index 3687ce5e..cb18204c 100644 --- a/test/towerops_web/live/network_map_live_test.exs +++ b/test/towerops_web/live/network_map_live_test.exs @@ -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= 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()