discovery working

This commit is contained in:
Graham McIntire 2026-01-25 14:48:57 -06:00
parent a30d6b8219
commit 9c5b4096f9
No known key found for this signature in database
5 changed files with 1367 additions and 101 deletions

View file

@ -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 """

View file

@ -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

View file

@ -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

View file

@ -27,87 +27,243 @@
</.button>
</:actions>
</.header>
<%= if !@has_sites do %>
<div class="text-center py-16">
<.icon
name="hero-building-office"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
/>
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
Create a site first
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Before adding device, you need to create at least one site location.
</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Sites help you organize your devices by physical location.
</p>
<div class="mt-6">
<.button navigate={~p"/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> Create Your First Site
</.button>
</div>
</div>
<% else %>
<%= if @device == [] do %>
<div class="text-center py-16">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">No devices</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Get started by adding your first device.
</p>
<div class="mt-6">
<.button
navigate={~p"/devices/new"}
variant="primary"
>
<.icon name="hero-plus" class="h-5 w-5" /> New Device
</.button>
</div>
</div>
<% else %>
<.table
id="devices"
rows={@device}
row_click={
fn eq ->
JS.navigate(~p"/devices/#{eq.id}")
<!-- Tab Navigation -->
<div class="border-b border-gray-200 dark:border-white/10 mb-6">
<nav class="-mb-px flex space-x-8">
<.link
patch={~p"/devices?tab=existing"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "existing" do
"border-blue-500 text-blue-600 dark:text-blue-400"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
end
}
]}
>
<:col :let={eq} label="Name">
<span class="font-medium">{eq.name}</span>
</:col>
<:col :let={eq} label="IP Address">
<span class="font-mono text-sm">{eq.ip_address}</span>
</:col>
<:col :let={eq} label="Site">
<.link
navigate={~p"/sites/#{eq.site.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
Existing Devices
</.link>
<.link
patch={~p"/devices?tab=discovered"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "discovered" do
"border-blue-500 text-blue-600 dark:text-blue-400"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
end
]}
>
Discovered Devices
<%= if @discovered_devices != [] do %>
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{length(@discovered_devices)}
</span>
<% end %>
</.link>
</nav>
</div>
<%= case @active_tab do %>
<% "existing" -> %>
<%= if !@has_sites do %>
<div class="text-center py-16">
<.icon
name="hero-building-office"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
/>
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
Create a site first
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Before adding device, you need to create at least one site location.
</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Sites help you organize your devices by physical location.
</p>
<div class="mt-6">
<.button navigate={~p"/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> Create Your First Site
</.button>
</div>
</div>
<% else %>
<%= if @device == [] do %>
<div class="text-center py-16">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">No devices</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Get started by adding your first device.
</p>
<div class="mt-6">
<.button
navigate={~p"/devices/new"}
variant="primary"
>
<.icon name="hero-plus" class="h-5 w-5" /> New Device
</.button>
</div>
</div>
<% else %>
<.table
id="devices"
rows={@device}
row_click={
fn eq ->
JS.navigate(~p"/devices/#{eq.id}")
end
}
>
{eq.site.name}
</.link>
</:col>
<:col :let={eq} label="Status">
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
eq.status == :up &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
eq.status == :down && "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
eq.status == :unknown &&
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
]}>
{eq.status |> to_string() |> String.upcase()}
</span>
</:col>
<:col :let={eq} label="Last Checked">
<span class="text-sm text-gray-600 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_datetime(eq.last_checked_at, @timezone)}
</span>
</:col>
</.table>
<% end %>
<:col :let={eq} label="Name">
<span class="font-medium">{eq.name}</span>
</:col>
<:col :let={eq} label="IP Address">
<span class="font-mono text-sm">{eq.ip_address}</span>
</:col>
<:col :let={eq} label="Site">
<.link
navigate={~p"/sites/#{eq.site.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{eq.site.name}
</.link>
</:col>
<:col :let={eq} label="Status">
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
eq.status == :up &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
eq.status == :down && "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
eq.status == :unknown &&
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
]}>
{eq.status |> to_string() |> String.upcase()}
</span>
</:col>
<:col :let={eq} label="Last Checked">
<span class="text-sm text-gray-600 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_datetime(eq.last_checked_at, @timezone)}
</span>
</:col>
</.table>
<% end %>
<% end %>
<% "discovered" -> %>
<%= if @discovered_devices == [] do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
<div class="text-center">
<.icon name="hero-magnifying-glass" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No undiscovered devices
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
All discovered devices have been added, or no devices have been discovered yet via LLDP, CDP, or ARP.
</p>
</div>
</div>
<% else %>
<.table id="discovered-devices" rows={@discovered_devices}>
<:col :let={discovered} label="Identifier">
<%= case discovered.identifier.type do %>
<% :mac -> %>
<div>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
MAC
</span>
<span class="ml-2 font-mono text-sm text-gray-900 dark:text-white">
{discovered.identifier.value}
</span>
</div>
<% :ip -> %>
<div>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
IP
</span>
<span class="ml-2 font-mono text-sm text-gray-900 dark:text-white">
{discovered.identifier.value}
</span>
</div>
<% :hostname -> %>
<div>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
HOST
</span>
<span class="ml-2 text-sm text-gray-900 dark:text-white">
{discovered.identifier.value}
</span>
</div>
<% _ -> %>
<span class="text-sm text-gray-500">Unknown</span>
<% end %>
</:col>
<:col :let={discovered} label="Hostname">
<%= if discovered.hostname do %>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{discovered.hostname}
</span>
<% else %>
<span class="text-sm text-gray-400">-</span>
<% end %>
</:col>
<:col :let={discovered} label="Type">
<div class="flex items-center gap-2">
<.icon
name={device_type_icon(discovered.device_type)}
class="h-4 w-4 text-gray-500"
/>
<span class="text-sm text-gray-900 dark:text-white">
{device_type_label(discovered.device_type)}
</span>
</div>
</:col>
<:col :let={discovered} label="Manufacturer">
<%= if discovered.manufacturer do %>
<span class="text-sm text-gray-900 dark:text-white">
{discovered.manufacturer}
</span>
<% else %>
<span class="text-sm text-gray-400">-</span>
<% end %>
</:col>
<:col :let={discovered} label="Discovered By">
<div class="text-sm text-gray-600 dark:text-gray-400">
<%= if length(discovered.discovered_by) == 1 do %>
<% [first] = discovered.discovered_by %>
<.link
navigate={~p"/devices/#{first.device_id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{first.device_name}
</.link>
<span class="text-xs text-gray-500"> ({first.interface})</span>
<% else %>
<span>{length(discovered.discovered_by)} devices</span>
<% end %>
</div>
</:col>
<:col :let={discovered} label="Last Seen">
<span class="text-sm text-gray-600 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_datetime(discovered.last_seen, @timezone)}
</span>
</:col>
<:col :let={discovered} label="Actions">
<.button
phx-click="add_discovered_device"
phx-value-identifier={Jason.encode!(discovered.identifier)}
variant="primary"
>
<.icon name="hero-plus" class="h-3 w-3" /> Add Device
</.button>
</:col>
</.table>
<% end %>
<% end %>
</Layouts.authenticated>

View file

@ -4,6 +4,7 @@ defmodule Towerops.SnmpTest do
import Towerops.AccountsFixtures
alias Towerops.Snmp
alias Towerops.Snmp.ArpEntry
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
@ -1652,8 +1653,8 @@ defmodule Towerops.SnmpTest do
|> Repo.insert!()
arp =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
@ -1685,8 +1686,8 @@ defmodule Towerops.SnmpTest do
|> Repo.insert!()
arp2 =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.200",
@ -1696,8 +1697,8 @@ defmodule Towerops.SnmpTest do
|> Repo.insert!()
arp1 =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
@ -1725,8 +1726,8 @@ defmodule Towerops.SnmpTest do
|> Repo.insert!()
arp =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
@ -1782,8 +1783,8 @@ defmodule Towerops.SnmpTest do
|> Repo.insert!()
existing =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
@ -1827,8 +1828,8 @@ defmodule Towerops.SnmpTest do
recent_time = DateTime.add(DateTime.utc_now(), -60, :second)
old_arp =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
@ -1838,8 +1839,8 @@ defmodule Towerops.SnmpTest do
|> Repo.insert!()
recent_arp =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.200",
@ -1880,8 +1881,8 @@ defmodule Towerops.SnmpTest do
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
arp1 =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device1.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
@ -1890,8 +1891,8 @@ defmodule Towerops.SnmpTest do
|> Repo.insert!()
arp2 =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device2.id,
ip_address: "192.168.1.100",
mac_address: "11:22:33:44:55:66",
@ -1969,4 +1970,327 @@ defmodule Towerops.SnmpTest do
assert hd(entries).if_index == 999
end
end
describe "list_discovered_devices_for_organization/1" do
test "merges data from neighbors, ARP, and MAC tables", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
# Create an interface
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create a neighbor entry
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "discovered-switch",
remote_platform: "Cisco IOS Software",
remote_capabilities: ["bridge", "router"],
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
# Create an ARP entry with same MAC
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
if_index: 1,
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
entry = hd(discovered)
# Should merge data from both sources
assert entry.identifier.type == :mac
assert entry.identifier.value == "aa:bb:cc:dd:ee:ff"
assert entry.hostname == "discovered-switch"
assert "192.168.1.100" in entry.ip_addresses
assert "aa:bb:cc:dd:ee:ff" in entry.mac_addresses
assert entry.source_count == 2
assert :lldp in entry.protocols_used
assert :arp in entry.protocols_used
end
test "filters out existing devices by IP address", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with same IP as existing device
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "test-router",
remote_address: device.ip_address,
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
# Should be filtered out
assert discovered == []
end
test "filters out existing devices by MAC address", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
if_phys_address: "aa:bb:cc:dd:ee:ff"
})
|> Repo.insert!()
# Create neighbor with same MAC as existing interface
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "discovered-device",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
# Should be filtered out
assert discovered == []
end
test "infers device type from LLDP capabilities", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with bridge capability
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "switch-device",
remote_capabilities: ["bridge"],
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
assert hd(discovered).device_type == :switch
end
test "infers device type from platform description", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with Cisco IOS description
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "router-device",
remote_platform: "Cisco IOS Software, Version 15.0",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
assert hd(discovered).device_type == :router
end
test "prefers MAC address as primary identifier", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with both MAC and IP
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "test-device",
remote_address: "192.168.1.100",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
entry = hd(discovered)
# Should prefer MAC as identifier
assert entry.identifier.type == :mac
assert entry.identifier.value == "aa:bb:cc:dd:ee:ff"
end
test "returns empty list when no discovery data exists", %{organization: organization} do
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert discovered == []
end
test "sorts by source count and last seen", %{organization: organization, device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create one neighbor with single source (older)
old_time = DateTime.add(DateTime.utc_now(), -3600, :second)
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "11:11:11:11:11:11",
remote_port_id: "Gi1/0/1",
remote_system_name: "old-device",
protocol: "lldp",
last_discovered_at: old_time
})
|> Repo.insert!()
# Create another neighbor with single source (newer)
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "22:22:22:22:22:22",
remote_port_id: "Gi1/0/2",
remote_system_name: "new-device",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
# Create a device with multiple sources
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "33:33:33:33:33:33",
remote_port_id: "Gi1/0/3",
remote_system_name: "multi-source",
protocol: "lldp",
last_discovered_at: old_time
})
|> Repo.insert!()
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
ip_address: "192.168.1.100",
mac_address: "33:33:33:33:33:33",
if_index: 1,
entry_type: "dynamic",
last_seen_at: old_time
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 3
# First should be multi-source (source_count = 2)
assert hd(discovered).hostname == "multi-source"
assert hd(discovered).source_count == 2
# Next two should be sorted by last_seen (newer first)
assert Enum.at(discovered, 1).hostname == "new-device"
assert Enum.at(discovered, 2).hostname == "old-device"
end
end
end