discovery working
This commit is contained in:
parent
a30d6b8219
commit
9c5b4096f9
5 changed files with 1367 additions and 101 deletions
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue