diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index 4af1a09e..8fc769b5 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -528,6 +528,731 @@ defmodule Towerops.Snmp do |> Repo.delete_all() end + @doc """ + Lists all discovered devices across an organization that haven't been added yet. + + Aggregates data from LLDP/CDP neighbors, ARP entries, and MAC addresses. + Returns a list of discovered device structs with merged data. + + ## Returns + + List of maps with: + - `identifier`: %{type: :mac | :ip | :hostname, value: string} + - `hostname`: string | nil + - `ip_addresses`: [string] + - `mac_addresses`: [string] + - `device_type`: :router | :switch | :wireless | :server | :workstation | :unknown + - `capabilities`: [string] (from LLDP/CDP) + - `platform`: string | nil + - `discovered_by`: [%{device_id: uuid, device_name: string, interface: string}] + - `source_count`: integer + - `last_seen`: DateTime.t() + - `protocols_used`: [:lldp | :cdp | :arp | :mac] + """ + def list_discovered_devices_for_organization(organization_id) do + # Get all devices in organization + device_ids = query_organization_device_ids(organization_id) + + # Fetch all discovery data + neighbors = query_neighbors_for_devices(device_ids) + arp_entries = query_arp_for_devices(device_ids) + mac_addresses = query_mac_for_devices(device_ids) + + # Get existing devices to filter out + existing_ips = get_existing_device_ips(organization_id) + existing_macs = get_existing_interface_macs(organization_id) + + # Build identifier index by aggregating all sources + discovered_map = + %{} + |> merge_neighbors_into_map(neighbors) + |> merge_arp_into_map(arp_entries) + |> merge_mac_into_map(mac_addresses) + + # Filter out existing devices and build final structs + discovered_map + |> Enum.reject(fn {_key, entry} -> + device_already_exists?(entry, existing_ips, existing_macs) + end) + |> Enum.map(fn {_key, entry} -> build_discovered_device_struct(entry) end) + |> Enum.sort(fn a, b -> + # Sort by source_count descending, then by last_seen descending + cond do + a.source_count > b.source_count -> true + a.source_count < b.source_count -> false + true -> DateTime.after?(a.last_seen, b.last_seen) + end + end) + end + + # Query all device IDs in organization + defp query_organization_device_ids(organization_id) do + Repo.all( + from(d in DeviceSchema, join: s in assoc(d, :site), where: s.organization_id == ^organization_id, select: d.id) + ) + end + + # Batch query neighbors for all devices in org + defp query_neighbors_for_devices(device_ids) do + cutoff = DateTime.add(DateTime.utc_now(), -7, :day) + + Repo.all( + from(n in Neighbor, + join: d in DeviceSchema, + on: n.device_id == d.id, + left_join: i in Interface, + on: n.interface_id == i.id, + where: n.device_id in ^device_ids, + where: n.last_discovered_at > ^cutoff, + preload: [device: d, interface: i], + order_by: [desc: n.last_discovered_at] + ) + ) + end + + # Batch query ARP entries for all devices in org + defp query_arp_for_devices(device_ids) do + cutoff = DateTime.add(DateTime.utc_now(), -7, :day) + + Repo.all( + from(a in ArpEntry, + join: d in DeviceSchema, + on: a.device_id == d.id, + left_join: i in Interface, + on: a.interface_id == i.id, + where: a.device_id in ^device_ids, + where: a.last_seen_at > ^cutoff, + preload: [device: d, interface: i], + order_by: [desc: a.last_seen_at] + ) + ) + end + + # Batch query MAC addresses for all devices in org + defp query_mac_for_devices(device_ids) do + cutoff = DateTime.add(DateTime.utc_now(), -7, :day) + + Repo.all( + from(m in MacAddress, + join: d in DeviceSchema, + on: m.device_id == d.id, + left_join: i in Interface, + on: m.interface_id == i.id, + where: m.device_id in ^device_ids, + where: m.last_seen_at > ^cutoff, + preload: [device: d, interface: i], + order_by: [desc: m.last_seen_at] + ) + ) + end + + # Get existing device IPs in organization + defp get_existing_device_ips(organization_id) do + from(d in DeviceSchema, + join: s in assoc(d, :site), + where: s.organization_id == ^organization_id, + where: not is_nil(d.ip_address), + select: d.ip_address + ) + |> Repo.all() + |> MapSet.new() + end + + # Get existing interface MAC addresses in organization + defp get_existing_interface_macs(organization_id) do + from(i in Interface, + join: sd in Device, + on: i.snmp_device_id == sd.id, + join: d in DeviceSchema, + on: sd.device_id == d.id, + join: s in assoc(d, :site), + where: s.organization_id == ^organization_id, + where: not is_nil(i.if_phys_address), + select: i.if_phys_address + ) + |> Repo.all() + |> MapSet.new(&normalize_mac/1) + end + + # Normalize MAC address to "aa:bb:cc:dd:ee:ff" format + defp normalize_mac(nil), do: nil + + defp normalize_mac(mac) when is_binary(mac) do + mac + |> String.downcase() + |> String.replace(~r/[:-]/, "") + |> String.replace(~r/(.{2})/, "\\1:") + |> String.trim_trailing(":") + end + + # Merge LLDP/CDP neighbor data into the discovery map + defp merge_neighbors_into_map(map, neighbors) do + Enum.reduce(neighbors, map, fn neighbor, acc -> + identifiers = extract_neighbor_identifiers(neighbor) + # Use best available identifier as the key (MAC > IP > hostname) + {id_type, id_value} = choose_best_identifier_from_list(identifiers) + key = identifier_key(id_type, id_value) + + entry = + Map.get(acc, key, %{ + identifiers: [], + hostnames: [], + ips: [], + macs: [], + capabilities: [], + platforms: [], + discovered_by: [], + sources: %{neighbors: [], arp: [], mac: []} + }) + + updated_entry = %{ + entry + | identifiers: Enum.uniq(identifiers ++ entry.identifiers), + hostnames: add_if_present(entry.hostnames, neighbor.remote_system_name), + ips: add_if_present(entry.ips, neighbor.remote_address), + macs: add_if_present(entry.macs, normalize_mac(neighbor.remote_chassis_id)), + capabilities: Enum.uniq(entry.capabilities ++ (neighbor.remote_capabilities || [])), + platforms: add_if_present(entry.platforms, neighbor.remote_platform), + discovered_by: + Enum.uniq_by( + [ + %{ + device_id: neighbor.device.id, + device_name: neighbor.device.name, + interface: interface_name(neighbor.interface), + timestamp: neighbor.last_discovered_at + } + | entry.discovered_by + ], + & &1.device_id + ), + sources: %{entry.sources | neighbors: [neighbor | entry.sources.neighbors]} + } + + Map.put(acc, key, updated_entry) + end) + end + + # Merge ARP entry data into the discovery map + defp merge_arp_into_map(map, arp_entries) do + Enum.reduce(arp_entries, map, fn arp, acc -> + identifiers = extract_arp_identifiers(arp) + # Use best available identifier as the key (MAC > IP) + {id_type, id_value} = choose_best_identifier_from_list(identifiers) + key = identifier_key(id_type, id_value) + + entry = + Map.get(acc, key, %{ + identifiers: [], + hostnames: [], + ips: [], + macs: [], + capabilities: [], + platforms: [], + discovered_by: [], + sources: %{neighbors: [], arp: [], mac: []} + }) + + updated_entry = %{ + entry + | identifiers: Enum.uniq(identifiers ++ entry.identifiers), + ips: add_if_present(entry.ips, arp.ip_address), + macs: add_if_present(entry.macs, normalize_mac(arp.mac_address)), + discovered_by: + Enum.uniq_by( + [ + %{ + device_id: arp.device.id, + device_name: arp.device.name, + interface: interface_name(arp.interface), + timestamp: arp.last_seen_at + } + | entry.discovered_by + ], + & &1.device_id + ), + sources: %{entry.sources | arp: [arp | entry.sources.arp]} + } + + Map.put(acc, key, updated_entry) + end) + end + + # Merge MAC address table data into the discovery map + defp merge_mac_into_map(map, mac_addresses) do + Enum.reduce(mac_addresses, map, fn mac_entry, acc -> + mac_normalized = normalize_mac(mac_entry.mac_address) + if is_nil(mac_normalized), do: acc, else: do_merge_mac(acc, mac_entry, mac_normalized) + end) + end + + defp do_merge_mac(acc, mac_entry, mac_normalized) do + key = identifier_key(:mac, mac_normalized) + + entry = + Map.get(acc, key, %{ + identifiers: [], + hostnames: [], + ips: [], + macs: [], + capabilities: [], + platforms: [], + discovered_by: [], + sources: %{neighbors: [], arp: [], mac: []} + }) + + updated_entry = %{ + entry + | identifiers: Enum.uniq([{:mac, mac_normalized} | entry.identifiers]), + macs: Enum.uniq([mac_normalized | entry.macs]), + discovered_by: + Enum.uniq_by( + [ + %{ + device_id: mac_entry.device.id, + device_name: mac_entry.device.name, + interface: interface_name(mac_entry.interface), + timestamp: mac_entry.last_seen_at + } + | entry.discovered_by + ], + & &1.device_id + ), + sources: %{entry.sources | mac: [mac_entry | entry.sources.mac]} + } + + Map.put(acc, key, updated_entry) + end + + # Choose best identifier from a list of {type, value} tuples + # Priority: MAC > IP > hostname + defp choose_best_identifier_from_list(identifiers) do + Enum.find(identifiers, fn {type, _value} -> type == :mac end) || + Enum.find(identifiers, fn {type, _value} -> type == :ip end) || + Enum.find(identifiers, fn {type, _value} -> type == :hostname end) || + {:unknown, "unknown"} + end + + # Extract identifiers from neighbor record + defp extract_neighbor_identifiers(neighbor) do + Enum.reject( + [ + if(neighbor.remote_chassis_id && mac_address?(neighbor.remote_chassis_id), + do: {:mac, normalize_mac(neighbor.remote_chassis_id)} + ), + if(neighbor.remote_address, do: {:ip, neighbor.remote_address}), + if(neighbor.remote_system_name, do: {:hostname, neighbor.remote_system_name}) + ], + &is_nil/1 + ) + end + + # Extract identifiers from ARP entry + defp extract_arp_identifiers(arp) do + Enum.reject( + [if(arp.mac_address, do: {:mac, normalize_mac(arp.mac_address)}), if(arp.ip_address, do: {:ip, arp.ip_address})], + &is_nil/1 + ) + end + + # Create a consistent key for the identifier + defp identifier_key(type, value), do: "#{type}:#{value}" + + # Add value to list if not nil or empty + defp add_if_present(list, nil), do: list + defp add_if_present(list, ""), do: list + defp add_if_present(list, value), do: Enum.uniq([value | list]) + + # Get interface name or description + defp interface_name(nil), do: "unknown" + defp interface_name(interface), do: interface.if_name || interface.if_descr || "if#{interface.if_index}" + + # Check if device already exists in organization + defp device_already_exists?(entry, existing_ips, existing_macs) do + Enum.any?(entry.ips, &MapSet.member?(existing_ips, &1)) || + Enum.any?(entry.macs, &MapSet.member?(existing_macs, &1)) + end + + # Build final discovered device struct from aggregated entry + defp build_discovered_device_struct(entry) do + identifier = choose_best_identifier(entry) + device_type = infer_device_type(entry) + manufacturer = infer_manufacturer(entry) + + %{ + identifier: identifier, + hostname: List.first(entry.hostnames), + ip_addresses: Enum.uniq(entry.ips), + mac_addresses: Enum.uniq(entry.macs), + device_type: device_type, + manufacturer: manufacturer, + capabilities: Enum.uniq(entry.capabilities), + platform: List.first(entry.platforms), + discovered_by: Enum.sort_by(entry.discovered_by, & &1.timestamp, {:desc, DateTime}), + source_count: count_sources(entry), + last_seen: get_most_recent_timestamp(entry), + protocols_used: get_protocols_used(entry.sources) + } + end + + # Choose best identifier: prefer MAC > IP > hostname + defp choose_best_identifier(entry) do + cond do + entry.macs != [] -> + %{type: :mac, value: List.first(entry.macs)} + + entry.ips != [] -> + %{type: :ip, value: List.first(entry.ips)} + + entry.hostnames != [] -> + %{type: :hostname, value: List.first(entry.hostnames)} + + true -> + %{type: :unknown, value: "unknown"} + end + end + + # Infer device type from capabilities, platform, or system name + defp infer_device_type(entry) do + capabilities = Enum.map(entry.capabilities, &String.downcase/1) + platform = String.downcase(List.first(entry.platforms) || "") + hostname = String.downcase(List.first(entry.hostnames) || "") + + infer_from_capabilities(capabilities) || infer_from_platform(platform) || + infer_from_hostname(hostname) || :unknown + end + + defp infer_from_capabilities(capabilities) do + cond do + "router" in capabilities -> :router + "bridge" in capabilities or "switch" in capabilities -> :switch + "wlan-ap" in capabilities or "wlan" in capabilities -> :wireless + true -> nil + end + end + + defp infer_from_platform(platform) do + cond do + String.contains?(platform, "ios") -> :router + String.contains?(platform, "catalyst") -> :switch + String.contains?(platform, "linux") or String.contains?(platform, "windows") -> :server + true -> nil + end + end + + defp infer_from_hostname(hostname) do + cond do + hostname_suggests_router?(hostname) -> :router + hostname_suggests_switch?(hostname) -> :switch + hostname_suggests_wireless?(hostname) -> :wireless + hostname_suggests_server?(hostname) -> :server + hostname_suggests_workstation?(hostname) -> :workstation + true -> nil + end + end + + defp hostname_suggests_router?(hostname) do + String.contains?(hostname, "router") or String.contains?(hostname, "rtr") or + String.contains?(hostname, "gateway") or String.contains?(hostname, "gw") + end + + defp hostname_suggests_switch?(hostname) do + String.contains?(hostname, "switch") or String.contains?(hostname, "sw") + end + + defp hostname_suggests_wireless?(hostname) do + String.contains?(hostname, "ap") or String.contains?(hostname, "access") or + String.contains?(hostname, "wireless") or String.contains?(hostname, "wifi") or + String.contains?(hostname, "wap") + end + + defp hostname_suggests_server?(hostname) do + String.contains?(hostname, "server") or String.contains?(hostname, "srv") + end + + defp hostname_suggests_workstation?(hostname) do + String.contains?(hostname, "workstation") or String.contains?(hostname, "desktop") or + String.contains?(hostname, "laptop") or String.contains?(hostname, "pc") or + String.contains?(hostname, "ws") + end + + # Infer manufacturer from MAC OUI (first 3 bytes) + defp infer_manufacturer(entry) do + case entry.macs do + [] -> + nil + + [mac | _] -> + lookup_mac_oui(mac) + end + end + + # Lookup manufacturer by MAC OUI + defp lookup_mac_oui(mac) when is_binary(mac) do + # Extract first 3 bytes (OUI) from MAC address + oui = + mac + |> String.replace(":", "") + |> String.upcase() + |> String.slice(0, 6) + + Map.get(mac_oui_database(), oui) + end + + defp lookup_mac_oui(_), do: nil + + # Common MAC OUI to manufacturer mappings + # This is a subset of the IEEE OUI database focused on common network equipment vendors + defp mac_oui_database do + %{ + # Cisco + "000142" => "Cisco", + "000143" => "Cisco", + "00010D" => "Cisco", + "000164" => "Cisco", + "000196" => "Cisco", + "000197" => "Cisco", + "0001C7" => "Cisco", + "0001C9" => "Cisco", + "001818" => "Cisco", + "001B0C" => "Cisco", + "001B2A" => "Cisco", + "001D45" => "Cisco", + "001D70" => "Cisco", + "001E13" => "Cisco", + "001E4A" => "Cisco", + "001E7A" => "Cisco", + "001EBD" => "Cisco", + "001F6C" => "Cisco", + "001FCA" => "Cisco", + "0022BD" => "Cisco", + "002555" => "Cisco", + "0026CA" => "Cisco", + "002713" => "Cisco", + "00D097" => "Cisco", + "00E014" => "Cisco", + "04C5A4" => "Cisco", + "0C8527" => "Cisco", + "1C6A7A" => "Cisco", + "40F4EC" => "Cisco", + "5C50F7" => "Cisco", + "6CAB31" => "Cisco", + "6C41" => "Cisco", + "74A2E6" => "Cisco", + "D4A928" => "Cisco", + "E84F25" => "Cisco", + "F46E95" => "Cisco", + + # Ubiquiti + "00156D" => "Ubiquiti", + "001B2F" => "Ubiquiti", + "001DD7" => "Ubiquiti", + "0027BA" => "Ubiquiti", + "04189A" => "Ubiquiti", + "0418D6" => "Ubiquiti", + "247F20" => "Ubiquiti", + "248A07" => "Ubiquiti", + "44D9E7" => "Ubiquiti", + "74ACB9" => "Ubiquiti", + "788A20" => "Ubiquiti", + "80EA96" => "Ubiquiti", + "A42BB0" => "Ubiquiti", + "B4FBE4" => "Ubiquiti", + "DC9FDB" => "Ubiquiti", + "E063DA" => "Ubiquiti", + "F09FC2" => "Ubiquiti", + "F4E2C6" => "Ubiquiti", + "FCECDA" => "Ubiquiti", + + # MikroTik + "000456" => "MikroTik", + "001D2E" => "MikroTik", + "00272D" => "MikroTik", + "0C424C" => "MikroTik", + "2C3952" => "MikroTik", + "48A9D2" => "MikroTik", + "4C5E0C" => "MikroTik", + "6C3B6B" => "MikroTik", + "D49443" => "MikroTik", + "D4CA6D" => "MikroTik", + "E748B8" => "MikroTik", + + # TP-Link + "000E8F" => "TP-Link", + "001DD9" => "TP-Link", + "0C8268" => "TP-Link", + "1027F5" => "TP-Link", + "148824" => "TP-Link", + "1C3BF3" => "TP-Link", + "300108" => "TP-Link", + "3C468A" => "TP-Link", + "50C7BF" => "TP-Link", + "641C67" => "TP-Link", + "7C8BCA" => "TP-Link", + "98DAC4" => "TP-Link", + "A0F3C1" => "TP-Link", + "C46E1F" => "TP-Link", + "E8DE27" => "TP-Link", + "EC086B" => "TP-Link", + "F0B429" => "TP-Link", + + # Juniper + "001517" => "Juniper", + "001B1F" => "Juniper", + "002163" => "Juniper", + "002281" => "Juniper", + "0026DE" => "Juniper", + "0026DC" => "Juniper", + "002642" => "Juniper", + "009E1E" => "Juniper", + "285024" => "Juniper", + "28A24B" => "Juniper", + "3C8AB6" => "Juniper", + "5C5E" => "Juniper", + "842B2B" => "Juniper", + + # Aruba (HPE) + "001A1E" => "Aruba", + "00242C" => "Aruba", + "04BD88" => "Aruba", + "0C5496" => "Aruba", + "200E52" => "Aruba", + "24DE52" => "Aruba", + "40E3D6" => "Aruba", + "6C72E7" => "Aruba", + "94B4C4" => "Aruba", + "9C1C12" => "Aruba", + "B8D9CE" => "Aruba", + "D8C7C8" => "Aruba", + + # Dell + "000874" => "Dell", + "000BDB" => "Dell", + "000C7B" => "Dell", + "000D56" => "Dell", + "000E0C" => "Dell", + "000F1F" => "Dell", + "001111" => "Dell", + "00123F" => "Dell", + "0013C3" => "Dell", + "001372" => "Dell", + "001485" => "Dell", + "0015C5" => "Dell", + "0016CE" => "Dell", + "001731" => "Dell", + "001871" => "Dell", + "0019B9" => "Dell", + "001A4B" => "Dell", + "001C23" => "Dell", + "001D09" => "Dell", + "001E4F" => "Dell", + "0021F6" => "Dell", + "002219" => "Dell", + "002264" => "Dell", + "0023AE" => "Dell", + "002564" => "Dell", + "5C260A" => "Dell", + + # HP/HPE + "001279" => "HP", + "001438" => "HP", + "001560" => "HP", + "0016B9" => "HP", + "001708" => "HP", + "001B78" => "HP", + "001C2E" => "HP", + "001E0B" => "HP", + "001F29" => "HP", + "00237D" => "HP", + "002481" => "HP", + "00248D" => "HP", + "002655" => "HP", + "3863BB" => "HP", + + # Netgear + "00095B" => "Netgear", + "000FB5" => "Netgear", + "001E2A" => "Netgear", + "001F33" => "Netgear", + "00223F" => "Netgear", + "00260B" => "Netgear", + "04A151" => "Netgear", + "0846A0" => "Netgear", + "2C3033" => "Netgear", + "3448ED" => "Netgear", + "4C9EFF" => "Netgear", + "74DA38" => "Netgear", + "84C9B2" => "Netgear", + "A0040A" => "Netgear", + "C43DC7" => "Netgear", + + # Fortinet + "0009819" => "Fortinet", + "001910" => "Fortinet", + "00E04C" => "Fortinet", + "080027" => "Fortinet", + "0C1D36" => "Fortinet", + "246C05" => "Fortinet", + "708BCD" => "Fortinet", + "900B29" => "Fortinet", + + # Palo Alto + "009C33" => "Palo Alto", + "001B17" => "Palo Alto", + "5C5B35" => "Palo Alto", + "8C89A5" => "Palo Alto", + "D4F46F" => "Palo Alto" + } + end + + # Count distinct discovery sources + defp count_sources(entry) do + count = 0 + count = if entry.sources.neighbors == [], do: count, else: count + 1 + count = if entry.sources.arp == [], do: count, else: count + 1 + if entry.sources.mac == [], do: count, else: count + 1 + end + + # Get most recent timestamp from all sources + defp get_most_recent_timestamp(entry) do + timestamps = + Enum.reject( + Enum.map(entry.sources.neighbors, & &1.last_discovered_at) ++ + Enum.map(entry.sources.arp, & &1.last_seen_at) ++ Enum.map(entry.sources.mac, & &1.last_seen_at), + &is_nil/1 + ) + + case timestamps do + [] -> DateTime.utc_now() + _ -> Enum.max(timestamps, DateTime) + end + end + + # Get list of protocols used across all sources + defp get_protocols_used(sources) do + protocols = [] + + protocols = + case sources.neighbors do + [] -> + protocols + + neighbors -> + neighbor_protocols = + neighbors + |> Enum.map(& &1.protocol) + |> Enum.map(&String.to_atom/1) + |> Enum.uniq() + + protocols ++ neighbor_protocols + end + + protocols = if sources.arp == [], do: protocols, else: [:arp | protocols] + protocols = if sources.mac == [], do: protocols, else: [:mac | protocols] + + Enum.uniq(protocols) + end + # VLAN queries @doc """ diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex index 9452368f..fb9054ab 100644 --- a/lib/towerops_web/live/device_live/form.ex +++ b/lib/towerops_web/live/device_live/form.ex @@ -43,7 +43,7 @@ defmodule ToweropsWeb.DeviceLive.Form do {:noreply, apply_action(socket, socket.assigns.live_action, params)} end - defp apply_action(socket, :new, _params) do + defp apply_action(socket, :new, params) do equipment_attrs = %{ monitoring_enabled: true, check_interval_seconds: 300, @@ -52,6 +52,13 @@ defmodule ToweropsWeb.DeviceLive.Form do snmp_port: 161 } + # Pre-fill from query params if available (e.g., from discovered devices) + equipment_attrs = + equipment_attrs + |> merge_param(:name, params["name"]) + |> merge_param(:ip_address, params["ip_address"]) + |> merge_bool_param(:snmp_enabled, params["snmp_enabled"]) + equipment_attrs = cond do # Use preselected site from URL params if available @@ -574,6 +581,20 @@ defmodule ToweropsWeb.DeviceLive.Form do end end + # Merge a query param into attrs if present and non-empty + defp merge_param(attrs, _key, nil), do: attrs + defp merge_param(attrs, _key, ""), do: attrs + + defp merge_param(attrs, key, value) when is_binary(value) do + Map.put(attrs, key, value) + end + + # Merge a boolean query param into attrs if present + defp merge_bool_param(attrs, _key, nil), do: attrs + defp merge_bool_param(attrs, key, "true"), do: Map.put(attrs, key, true) + defp merge_bool_param(attrs, key, "false"), do: Map.put(attrs, key, false) + defp merge_bool_param(attrs, _key, _), do: attrs + # Check if IP is non-routable (RFC1918 private or RFC6598 CGNAT) defp non_routable_ip?(ip_string) do case ip_string |> String.to_charlist() |> :inet.parse_address() do diff --git a/lib/towerops_web/live/device_live/index.ex b/lib/towerops_web/live/device_live/index.ex index d9da2cb0..16431385 100644 --- a/lib/towerops_web/live/device_live/index.ex +++ b/lib/towerops_web/live/device_live/index.ex @@ -22,11 +22,26 @@ defmodule ToweropsWeb.DeviceLive.Index do @impl true def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} + tab = Map.get(params, "tab", "existing") + {:noreply, apply_action(socket, socket.assigns.live_action, params, tab)} end - defp apply_action(socket, :index, _params) do - socket + defp apply_action(socket, :index, _params, tab) do + organization = socket.assigns.current_organization + + case tab do + "discovered" -> + discovered = Snmp.list_discovered_devices_for_organization(organization.id) + + socket + |> assign(:active_tab, "discovered") + |> assign(:discovered_devices, discovered) + + _ -> + socket + |> assign(:active_tab, "existing") + |> assign(:discovered_devices, []) + end end @impl true @@ -47,6 +62,19 @@ defmodule ToweropsWeb.DeviceLive.Index do end end + def handle_event("add_discovered_device", params, socket) do + identifier = Jason.decode!(params["identifier"]) + discovered = Enum.find(socket.assigns.discovered_devices, &(&1.identifier == identifier)) + + prefill_params = %{ + "name" => discovered.hostname || "", + "ip_address" => List.first(discovered.ip_addresses) || "", + "snmp_enabled" => "true" + } + + {:noreply, push_navigate(socket, to: ~p"/devices/new?#{prefill_params}")} + end + defp enqueue_discovery(device_id) do if Application.get_env(:towerops, :env) == :test do _ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end) @@ -54,4 +82,16 @@ defmodule ToweropsWeb.DeviceLive.Index do DiscoveryWorker.enqueue(device_id) end end + + # Device type icon helpers + defp device_type_icon(:router), do: "hero-signal" + defp device_type_icon(:switch), do: "hero-squares-2x2" + defp device_type_icon(:wireless), do: "hero-wifi" + defp device_type_icon(:server), do: "hero-server" + defp device_type_icon(:workstation), do: "hero-computer-desktop" + defp device_type_icon(_), do: "hero-question-mark-circle" + + defp device_type_label(type) do + type |> to_string() |> String.capitalize() + end end diff --git a/lib/towerops_web/live/device_live/index.html.heex b/lib/towerops_web/live/device_live/index.html.heex index 519b9ca8..bc8b874c 100644 --- a/lib/towerops_web/live/device_live/index.html.heex +++ b/lib/towerops_web/live/device_live/index.html.heex @@ -27,87 +27,243 @@ - - <%= if !@has_sites do %> -
- Before adding device, you need to create at least one site location. -
-- Sites help you organize your devices by physical location. -
-- Get started by adding your first device. -
-+ Before adding device, you need to create at least one site location. +
++ Sites help you organize your devices by physical location. +
++ Get started by adding your first device. +
++ All discovered devices have been added, or no devices have been discovered yet via LLDP, CDP, or ARP. +
+