towerops/lib/towerops_web/live/network_map_live.ex
Graham McIntire 80304329bd
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
2026-03-12 10:59:20 -05:00

127 lines
3.3 KiB
Elixir

defmodule ToweropsWeb.NetworkMapLive do
@moduledoc false
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 if connected
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "topology:#{organization.id}")
end
# Default empty topology
default_topology = %{
nodes: [],
edges: [],
stats: %{
total_devices: 0,
added_devices: 0,
discovered_devices: 0,
total_links: 0
},
last_updated: DateTime.utc_now()
}
{:ok,
socket
|> assign(:page_title, t("Network Map"))
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:loading, true)
|> assign(:topology, default_topology)
|> assign(:active_tab, "added")
|> assign(:selected_node_detail, nil)}
end
@impl true
def handle_params(params, _url, socket) do
tab = Map.get(params, "tab", "added")
organization = socket.assigns.current_scope.organization
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
def handle_event("refresh_topology", _params, socket) do
organization = socket.assigns.current_scope.organization
{:noreply,
socket
|> assign(:loading, true)
|> load_topology_data(organization.id, socket.assigns.active_tab)
|> put_flash(:info, t("Network map refreshed"))}
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_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
)}
end
defp load_topology_data(socket, organization_id, tab) do
topology = Topology.get_topology_for_map(organization_id, tab)
socket
|> assign(:topology, topology)
|> 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