mac discovery

This commit is contained in:
Graham McIntire 2026-01-23 09:22:08 -06:00
parent 7bd3b1f9ec
commit 80d1864700
No known key found for this signature in database
10 changed files with 1023 additions and 28 deletions

View file

@ -16,6 +16,7 @@ defmodule Towerops.Snmp do
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.Snmp.IpAddress
alias Towerops.Snmp.MacAddress
alias Towerops.Snmp.Neighbor
alias Towerops.Snmp.Processor
alias Towerops.Snmp.ProcessorReading
@ -815,4 +816,72 @@ defmodule Towerops.Snmp do
length(arp_entries)
end
# MAC Address queries
@doc """
Lists all MAC address entries for a device.
"""
def list_mac_addresses(device_id) do
MacAddress
|> where([m], m.device_id == ^device_id)
|> order_by([m], [m.vlan_id, m.mac_address])
|> preload(:interface)
|> Repo.all()
end
@doc """
Creates or updates a MAC address entry.
"""
def upsert_mac_address(attrs) do
case Repo.get_by(MacAddress,
device_id: attrs.device_id,
mac_address: attrs.mac_address,
vlan_id: attrs[:vlan_id]
) do
nil ->
%MacAddress{}
|> MacAddress.changeset(attrs)
|> Repo.insert()
mac_address ->
mac_address
|> MacAddress.changeset(attrs)
|> Repo.update()
end
end
@doc """
Deletes stale MAC address entries that haven't been seen since the given datetime.
"""
def delete_stale_mac_addresses(device_id, cutoff_datetime) do
MacAddress
|> where([m], m.device_id == ^device_id)
|> where([m], m.last_seen_at < ^cutoff_datetime)
|> Repo.delete_all()
end
@doc """
Bulk upserts MAC address entries for a device.
Returns the number of entries processed.
"""
def upsert_mac_addresses(device_id, mac_entries, interfaces) do
# Build a map from bridge port index to interface ID
# Note: port_index from BRIDGE-MIB is not the same as if_index
# For now, we'll use a simple mapping if they're available
interface_map = Map.new(interfaces, fn i -> {i.if_index, i.id} end)
Enum.each(mac_entries, fn entry ->
interface_id = Map.get(interface_map, entry.port_index)
attrs =
entry
|> Map.put(:device_id, device_id)
|> Map.put(:interface_id, interface_id)
upsert_mac_address(attrs)
end)
length(mac_entries)
end
end

View file

@ -0,0 +1,49 @@
defmodule Towerops.Snmp.MacAddress do
@moduledoc """
SNMP MAC address schema for bridge forwarding database entries.
Stores MAC address table entries discovered via BRIDGE-MIB
dot1dTpFdbTable (1.3.6.1.2.1.17.4.3) and Q-BRIDGE-MIB
dot1qTpFdbTable (1.3.6.1.2.1.17.7.1.2.2).
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_mac_addresses" do
field :mac_address, :string
field :vlan_id, :integer
field :port_index, :integer
field :entry_status, :string
field :last_seen_at, :utc_datetime
belongs_to :device, Towerops.Devices.Device
belongs_to :interface, Towerops.Snmp.Interface
timestamps(type: :utc_datetime)
end
@doc false
def changeset(mac_address, attrs) do
mac_address
|> cast(attrs, [
:device_id,
:interface_id,
:mac_address,
:vlan_id,
:port_index,
:entry_status,
:last_seen_at
])
|> validate_required([
:device_id,
:mac_address,
:last_seen_at
])
|> foreign_key_constraint(:device_id)
|> foreign_key_constraint(:interface_id)
|> unique_constraint([:device_id, :mac_address, :vlan_id])
end
end

View file

@ -0,0 +1,218 @@
defmodule Towerops.Snmp.MacDiscovery do
@moduledoc """
Discovers MAC address forwarding table entries via SNMP.
Uses BRIDGE-MIB dot1dTpFdbTable (1.3.6.1.2.1.17.4.3) and Q-BRIDGE-MIB
dot1qTpFdbTable (1.3.6.1.2.1.17.7.1.2.2) to retrieve MAC address
forwarding database entries from network switches.
"""
alias Towerops.Snmp.Client
require Logger
@type mac_entry :: %{
required(:mac_address) => String.t(),
required(:port_index) => integer(),
optional(:vlan_id) => integer(),
optional(:entry_status) => String.t(),
required(:last_seen_at) => DateTime.t()
}
# BRIDGE-MIB FDB table OIDs
# dot1dTpFdbTable - basic FDB without VLAN awareness
@dot1d_tp_fdb_address "1.3.6.1.2.1.17.4.3.1.1"
@dot1d_tp_fdb_port "1.3.6.1.2.1.17.4.3.1.2"
@dot1d_tp_fdb_status "1.3.6.1.2.1.17.4.3.1.3"
# Q-BRIDGE-MIB FDB table OIDs (VLAN-aware)
@dot1q_tp_fdb_port "1.3.6.1.2.1.17.7.1.2.2.1.2"
@dot1q_tp_fdb_status "1.3.6.1.2.1.17.7.1.2.2.1.3"
@doc """
Discovers MAC address forwarding table entries for a device.
Returns {:ok, mac_entries} where mac_entries is a list of mac_entry maps.
"""
@spec discover_mac_table(Client.connection_opts()) :: {:ok, [mac_entry()]}
def discover_mac_table(client_opts) do
# Try Q-BRIDGE-MIB first (VLAN-aware), fall back to BRIDGE-MIB
case discover_qbridge_fdb(client_opts) do
{:ok, [_ | _] = entries} ->
{:ok, entries}
_ ->
discover_bridge_fdb(client_opts)
end
end
# Discover using Q-BRIDGE-MIB (VLAN-aware)
defp discover_qbridge_fdb(client_opts) do
port_map = fetch_qbridge_ports(client_opts)
status_map = fetch_qbridge_status(client_opts)
if map_size(port_map) == 0 do
{:ok, []}
else
entries =
port_map
|> Enum.map(fn {key, port_index} ->
{vlan_id, mac_address} = parse_qbridge_key(key)
status = Map.get(status_map, key)
%{
mac_address: mac_address,
port_index: port_index,
vlan_id: vlan_id,
entry_status: fdb_status_to_string(status),
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
end)
|> Enum.reject(&is_nil(&1.mac_address))
{:ok, entries}
end
end
# Discover using BRIDGE-MIB (no VLAN awareness)
defp discover_bridge_fdb(client_opts) do
mac_map = fetch_bridge_macs(client_opts)
port_map = fetch_bridge_ports(client_opts)
status_map = fetch_bridge_status(client_opts)
if map_size(mac_map) == 0 do
{:ok, []}
else
entries =
mac_map
|> Enum.map(fn {key, _mac_binary} ->
mac_address = parse_mac_from_key(key)
port_index = Map.get(port_map, key)
status = Map.get(status_map, key)
%{
mac_address: mac_address,
port_index: port_index,
vlan_id: nil,
entry_status: fdb_status_to_string(status),
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
end)
|> Enum.reject(&(is_nil(&1.mac_address) or is_nil(&1.port_index)))
{:ok, entries}
end
end
# Fetch helpers for Q-BRIDGE-MIB
defp fetch_qbridge_ports(client_opts) do
case Client.walk(client_opts, @dot1q_tp_fdb_port) do
{:ok, entries} when map_size(entries) > 0 ->
Map.new(entries, fn {oid, value} ->
key = extract_index(oid, @dot1q_tp_fdb_port)
{key, value}
end)
_ ->
%{}
end
end
defp fetch_qbridge_status(client_opts) do
case Client.walk(client_opts, @dot1q_tp_fdb_status) do
{:ok, entries} when map_size(entries) > 0 ->
Map.new(entries, fn {oid, value} ->
key = extract_index(oid, @dot1q_tp_fdb_status)
{key, value}
end)
_ ->
%{}
end
end
# Fetch helpers for BRIDGE-MIB
defp fetch_bridge_macs(client_opts) do
case Client.walk(client_opts, @dot1d_tp_fdb_address) do
{:ok, entries} when map_size(entries) > 0 ->
Map.new(entries, fn {oid, value} ->
key = extract_index(oid, @dot1d_tp_fdb_address)
{key, value}
end)
_ ->
%{}
end
end
defp fetch_bridge_ports(client_opts) do
case Client.walk(client_opts, @dot1d_tp_fdb_port) do
{:ok, entries} when map_size(entries) > 0 ->
Map.new(entries, fn {oid, value} ->
key = extract_index(oid, @dot1d_tp_fdb_port)
{key, value}
end)
_ ->
%{}
end
end
defp fetch_bridge_status(client_opts) do
case Client.walk(client_opts, @dot1d_tp_fdb_status) do
{:ok, entries} when map_size(entries) > 0 ->
Map.new(entries, fn {oid, value} ->
key = extract_index(oid, @dot1d_tp_fdb_status)
{key, value}
end)
_ ->
%{}
end
end
# Extract the index portion from an OID
defp extract_index(oid, base_oid) do
base_len = String.length(base_oid) + 1
String.slice(oid, base_len..-1//1)
end
# Parse Q-BRIDGE key format: vlan_id.mac_octet1.mac_octet2...mac_octet6
defp parse_qbridge_key(key) do
case String.split(key, ".") do
[vlan_id | mac_parts] when length(mac_parts) == 6 ->
mac_address = format_mac_from_octets(mac_parts)
{String.to_integer(vlan_id), mac_address}
_ ->
{nil, nil}
end
end
# Parse MAC from BRIDGE-MIB key format: mac_octet1.mac_octet2...mac_octet6
defp parse_mac_from_key(key) do
parts = String.split(key, ".")
if length(parts) == 6 do
format_mac_from_octets(parts)
end
end
# Format MAC address from octet strings
defp format_mac_from_octets(octets) when length(octets) == 6 do
octets
|> Enum.map(&String.to_integer/1)
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|> String.downcase()
end
defp format_mac_from_octets(_), do: nil
# Convert FDB status integer to string
# 1 = other, 2 = invalid, 3 = learned, 4 = self, 5 = mgmt
defp fdb_status_to_string(1), do: "other"
defp fdb_status_to_string(2), do: "invalid"
defp fdb_status_to_string(3), do: "learned"
defp fdb_status_to_string(4), do: "self"
defp fdb_status_to_string(5), do: "mgmt"
defp fdb_status_to_string(_), do: nil
end

View file

@ -1,9 +1,10 @@
defmodule Towerops.Snmp.NeighborCleanupWorker do
@moduledoc """
GenServer that periodically cleans up stale neighbor and ARP records.
GenServer that periodically cleans up stale neighbor, ARP, and MAC FDB records.
Neighbors and ARP entries that haven't been seen in the last 24 hours are
considered stale and will be automatically removed from the database.
Neighbors, ARP entries, and MAC forwarding database entries that haven't been
seen in the last 24 hours are considered stale and will be automatically
removed from the database.
"""
use GenServer
@ -39,38 +40,39 @@ defmodule Towerops.Snmp.NeighborCleanupWorker do
defp cleanup_stale_records do
cutoff = DateTime.add(DateTime.utc_now(), -@stale_threshold_hours, :hour)
Logger.info("Starting neighbor and ARP cleanup for records older than #{cutoff}")
Logger.info("Starting neighbor, ARP, and MAC cleanup for records older than #{cutoff}")
# device IDs with SNMP enabled
equipment_list = Devices.list_snmp_enabled_devices()
{total_neighbors, total_arp} =
Enum.reduce(equipment_list, {0, 0}, fn device, {neighbor_acc, arp_acc} ->
neighbors_deleted =
case Snmp.delete_stale_neighbors(device.id, cutoff) do
{count, _} when count > 0 ->
Logger.info("Deleted #{count} stale neighbors for device #{device.name} (#{device.id})")
count
{total_neighbors, total_arp, total_mac} =
Enum.reduce(equipment_list, {0, 0, 0}, fn device, {neighbor_acc, arp_acc, mac_acc} ->
neighbors_deleted = delete_stale_records(device, cutoff, &Snmp.delete_stale_neighbors/2, "neighbors")
arp_deleted = delete_stale_records(device, cutoff, &Snmp.delete_stale_arp_entries/2, "ARP entries")
mac_deleted = delete_stale_records(device, cutoff, &Snmp.delete_stale_mac_addresses/2, "MAC entries")
_ ->
0
end
arp_deleted =
case Snmp.delete_stale_arp_entries(device.id, cutoff) do
{count, _} when count > 0 ->
Logger.info("Deleted #{count} stale ARP entries for device #{device.name} (#{device.id})")
count
_ ->
0
end
{neighbor_acc + neighbors_deleted, arp_acc + arp_deleted}
{neighbor_acc + neighbors_deleted, arp_acc + arp_deleted, mac_acc + mac_deleted}
end)
if total_neighbors > 0 or total_arp > 0 do
Logger.info("Cleanup completed: removed #{total_neighbors} neighbors, #{total_arp} ARP entries")
log_cleanup_results(total_neighbors, total_arp, total_mac)
end
defp delete_stale_records(device, cutoff, delete_fn, record_type) do
case delete_fn.(device.id, cutoff) do
{count, _} when count > 0 ->
Logger.info("Deleted #{count} stale #{record_type} for device #{device.name} (#{device.id})")
count
_ ->
0
end
end
defp log_cleanup_results(total_neighbors, total_arp, total_mac) do
if total_neighbors > 0 or total_arp > 0 or total_mac > 0 do
Logger.info(
"Cleanup completed: removed #{total_neighbors} neighbors, #{total_arp} ARP entries, #{total_mac} MAC entries"
)
else
Logger.debug("Cleanup completed: no stale records found")
end

View file

@ -18,6 +18,7 @@ defmodule Towerops.Snmp.PollerWorker do
alias Towerops.Snmp
alias Towerops.Snmp.ArpDiscovery
alias Towerops.Snmp.Client
alias Towerops.Snmp.MacDiscovery
alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.PollerRegistry
alias Towerops.Snmp.PollerTaskSupervisor
@ -171,6 +172,9 @@ defmodule Towerops.Snmp.PollerWorker do
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
poll_device_arp(device, snmp_device, client_opts)
end),
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
poll_device_mac(device, snmp_device, client_opts)
end),
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
poll_device_processors(device, snmp_device, client_opts, now)
end),
@ -296,6 +300,29 @@ defmodule Towerops.Snmp.PollerWorker do
Logger.error("Error polling ARP table for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
end
defp poll_device_mac(device, snmp_device, client_opts) do
{:ok, mac_entries} = MacDiscovery.discover_mac_table(client_opts)
# Delete stale MAC entries (not seen in last 5 minutes)
cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
Snmp.delete_stale_mac_addresses(device.id, cutoff)
# Upsert MAC entries with interface mapping
Snmp.upsert_mac_addresses(device.id, mac_entries, snmp_device.interfaces)
Logger.debug("Polled and saved #{length(mac_entries)} MAC FDB entries for #{device.name}")
# Broadcast MAC update event to device-specific topic
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:mac_updated, device.id}
)
rescue
error ->
Logger.error("Error polling MAC FDB table for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
end
defp poll_device_processors(device, snmp_device, client_opts, now) do
processors = snmp_device.processors || []

View file

@ -2,8 +2,10 @@ defmodule ToweropsWeb.DeviceLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Agents
alias Towerops.Devices
alias Towerops.Monitoring
alias Towerops.Repo
alias Towerops.Snmp
# SNMP interface type to category mappings
@ -113,6 +115,11 @@ defmodule ToweropsWeb.DeviceLive.Show do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:mac_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:state_sensors_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
@ -144,11 +151,15 @@ defmodule ToweropsWeb.DeviceLive.Show do
defp load_equipment_data(socket, device_id) do
device = Devices.get_device!(device_id)
device_with_associations = Repo.preload(device, site: [organization: :default_agent_token])
{agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations)
agent_info = load_agent_info(agent_token_id, agent_source)
recent_checks = Monitoring.list_devices_checks(device_id, 50)
snmp_data = load_snmp_data(device_id)
events = Devices.list_devices_events(device_id, 100)
neighbors = Snmp.list_neighbors_with_equipment(device_id)
arp_entries = Snmp.list_arp_entries(device_id)
mac_addresses = Snmp.list_mac_addresses(device_id)
vlans = load_vlans(snmp_data.device)
ip_addresses = load_ip_addresses(snmp_data.device)
processors = load_processors(snmp_data.device)
@ -157,6 +168,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
processor_chart_data = load_processor_chart_data(processors)
memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"])
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
storage_volume_chart_data = load_storage_volume_chart_data(storage)
traffic_chart_data = load_overall_traffic_chart_data(device_id)
# Filter sensors by type for temperature, voltage, storage, count, and transceivers
@ -195,9 +207,11 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> assign(:snmp_sensors, snmp_data.sensors)
|> assign(:neighbors, neighbors)
|> assign(:arp_entries, arp_entries)
|> assign(:mac_addresses, mac_addresses)
|> assign(:vlans, vlans)
|> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4")))
|> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6")))
|> assign(:ip_addresses_by_interface, group_ip_addresses_by_interface(ip_addresses))
|> assign(:processors, processors)
|> assign(:storage, storage)
|> assign(:events, events)
@ -205,12 +219,34 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> assign(:processor_chart_data, processor_chart_data)
|> assign(:memory_chart_data, memory_chart_data)
|> assign(:storage_chart_data, storage_chart_data)
|> assign(:storage_volume_chart_data, storage_volume_chart_data)
|> assign(:traffic_chart_data, traffic_chart_data)
|> assign(:temperature_sensors, temperature_sensors)
|> assign(:voltage_sensors, voltage_sensors)
|> assign(:storage_sensors, storage_sensors)
|> assign(:count_sensors, count_sensors)
|> assign(:grouped_transceivers, grouped_transceivers)
|> assign(:agent_info, agent_info)
end
defp load_agent_info(nil, :none) do
%{
type: :cloud,
name: "Cloud Polling",
source: nil,
last_seen_at: nil
}
end
defp load_agent_info(agent_token_id, source) do
agent_token = Agents.get_agent_token!(agent_token_id)
%{
type: :agent,
name: agent_token.name,
source: source,
last_seen_at: agent_token.last_seen_at
}
end
defp calculate_metrics(checks, _equipment) do
@ -354,6 +390,19 @@ defmodule ToweropsWeb.DeviceLive.Show do
format_duration(diff) <> " ago"
end
defp format_relative_time(nil), do: "never"
defp format_relative_time(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
diff < 60 -> "just now"
diff < 3600 -> "#{div(diff, 60)} min ago"
diff < 86_400 -> "#{div(diff, 3600)} hours ago"
true -> format_duration(diff) <> " ago"
end
end
defp format_event_type(event_type) do
event_type
|> String.replace("_", " ")
@ -449,6 +498,44 @@ defmodule ToweropsWeb.DeviceLive.Show do
}
end
defp load_storage_volume_chart_data([]), do: nil
defp load_storage_volume_chart_data(storage_volumes) do
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
datasets =
storage_volumes
|> Enum.map(&storage_volume_to_chart_dataset(&1, twenty_four_hours_ago))
|> Enum.reject(&Enum.empty?(&1.data))
if Enum.empty?(datasets) do
nil
else
Jason.encode!(%{datasets: datasets})
end
end
defp storage_volume_to_chart_dataset(storage, since) do
readings =
storage.id
|> Snmp.get_storage_readings(since: since, limit: 1000)
|> Enum.reverse()
label = storage.description || "Storage #{storage.storage_index}"
%{
label: label,
data: Enum.map(readings, &storage_reading_to_data_point/1)
}
end
defp storage_reading_to_data_point(reading) do
%{
x: DateTime.to_unix(reading.checked_at, :millisecond),
y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1))
}
end
defp load_sensor_chart_data(device_id, sensor_types) when is_list(sensor_types) do
case Snmp.get_device_with_associations(device_id) do
nil -> nil
@ -652,4 +739,9 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> String.downcase()
end
end
# Group IP addresses by interface ID for efficient lookup in templates
defp group_ip_addresses_by_interface(ip_addresses) do
Enum.group_by(ip_addresses, & &1.snmp_interface_id)
end
end

View file

@ -30,6 +30,35 @@
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@device.name}</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 font-mono">{@device.ip_address}</p>
<!-- Poller Indicator -->
<div class="mt-2 flex items-center gap-2">
<%= if @agent_info.type == :cloud do %>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
<.icon name="hero-cloud" class="h-3.5 w-3.5" /> Cloud Polling
</span>
<% else %>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300">
<.icon name="hero-server" class="h-3.5 w-3.5" />
{@agent_info.name}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
<%= case @agent_info.source do %>
<% :device -> %>
(assigned directly)
<% :site -> %>
(via site default)
<% :organization -> %>
(via org default)
<% _ -> %>
<% end %>
</span>
<%= if @agent_info.last_seen_at do %>
<span class="text-xs text-gray-500 dark:text-gray-400">
&bull; Last seen: {format_relative_time(@agent_info.last_seen_at)}
</span>
<% end %>
<% end %>
</div>
</div>
<div class="flex gap-2">
<.button navigate={~p"/devices/#{@device.id}/edit"}>
@ -100,6 +129,22 @@
</.link>
<% end %>
<%= if @mac_addresses && length(@mac_addresses) > 0 do %>
<.link
patch={~p"/devices/#{@device.id}?tab=mac"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "mac" 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
]}
>
MAC Table
</.link>
<% end %>
<%= if @vlans && length(@vlans) > 0 do %>
<.link
patch={~p"/devices/#{@device.id}?tab=vlans"}
@ -487,6 +532,20 @@
</span>
</h3>
</div>
<!-- Storage Volume Chart -->
<%= if @storage_volume_chart_data do %>
<div class="p-4 border-b border-gray-200 dark:border-white/10">
<div
id="storage-volume-chart"
phx-hook="SensorChart"
data-chart={@storage_volume_chart_data}
data-unit="%"
style="height: 200px;"
>
<canvas></canvas>
</div>
</div>
<% end %>
<div class="p-4 space-y-4">
<%= for storage <- @storage do %>
<% usage_percent =
@ -708,6 +767,7 @@
<th class="px-4 py-3 text-left font-medium">Name</th>
<th class="px-4 py-3 text-left font-medium">Status</th>
<th class="px-4 py-3 text-left font-medium">Speed</th>
<th class="px-4 py-3 text-left font-medium">IP Address</th>
<th class="px-4 py-3 text-left font-medium">MAC</th>
</tr>
</thead>
@ -750,6 +810,24 @@
<td class="px-4 py-3 text-gray-900 dark:text-white">
{format_speed(interface.if_speed)}
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
<% interface_ips =
Map.get(@ip_addresses_by_interface, interface.id, []) %>
<%= if Enum.empty?(interface_ips) do %>
<span class="text-gray-400">-</span>
<% else %>
<div class="space-y-0.5">
<%= for ip <- interface_ips do %>
<div>
{ip.ip_address}
<%= if ip.prefix_length do %>
/{ip.prefix_length}
<% end %>
</div>
<% end %>
</div>
<% end %>
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
{interface.if_phys_address || "-"}
</td>
@ -955,6 +1033,92 @@
</div>
</div>
<% end %>
<% "mac" -> %>
<%= if @mac_addresses && length(@mac_addresses) > 0 do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
MAC Address Table
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
({length(@mac_addresses)} entries)
</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th class="px-4 py-3 text-left font-medium">MAC Address</th>
<th class="px-4 py-3 text-left font-medium">VLAN</th>
<th class="px-4 py-3 text-left font-medium">Interface</th>
<th class="px-4 py-3 text-left font-medium">Status</th>
<th class="px-4 py-3 text-left font-medium">Last Seen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for mac <- @mac_addresses do %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 font-mono text-xs text-gray-900 dark:text-white">
{mac.mac_address}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
<%= if mac.vlan_id do %>
{mac.vlan_id}
<% else %>
<span class="text-gray-400">-</span>
<% end %>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
<%= if mac.interface do %>
<.link
navigate={
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{mac.interface.id}"
}
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{mac.interface.if_name || mac.interface.if_descr}
</.link>
<% else %>
<span class="text-gray-400">Port {mac.port_index}</span>
<% end %>
</td>
<td class="px-4 py-3">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
mac.entry_status == "learned" &&
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
mac.entry_status == "self" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
mac.entry_status == "mgmt" &&
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
mac.entry_status not in ["learned", "self", "mgmt"] &&
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
]}>
{mac.entry_status || "unknown"}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{time_ago(mac.last_seen_at)}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% else %>
<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-table-cells" 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 MAC addresses discovered
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
This device doesn't have any MAC forwarding entries, or MAC discovery hasn't run yet.
</p>
</div>
</div>
<% end %>
<% "vlans" -> %>
<%= if @vlans && length(@vlans) > 0 do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">

View file

@ -0,0 +1,23 @@
defmodule Towerops.Repo.Migrations.CreateSnmpMacAddresses do
use Ecto.Migration
def change do
create table(:snmp_mac_addresses, primary_key: false) do
add :id, :binary_id, primary_key: true
add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false
add :interface_id, references(:snmp_interfaces, type: :binary_id, on_delete: :delete_all)
add :mac_address, :string, null: false
add :vlan_id, :integer
add :port_index, :integer
add :entry_status, :string
add :last_seen_at, :utc_datetime, null: false
timestamps(type: :utc_datetime)
end
create index(:snmp_mac_addresses, [:device_id])
create index(:snmp_mac_addresses, [:interface_id])
create index(:snmp_mac_addresses, [:mac_address])
create unique_index(:snmp_mac_addresses, [:device_id, :mac_address, :vlan_id])
end
end

View file

@ -0,0 +1,167 @@
defmodule Towerops.Snmp.MacAddressTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.MacAddress
setup do
user = user_fixture()
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, device_schema} =
Towerops.Devices.create_device(%{
name: "Test Switch",
ip_address: "192.168.1.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id
})
snmp_device =
%Device{}
|> Device.changeset(%{
device_id: device_schema.id,
sys_name: "test-switch",
sys_descr: "Test Switch"
})
|> Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "GigabitEthernet0/1",
if_name: "Gi0/1",
if_type: 6,
if_oper_status: "up"
})
|> Repo.insert!()
%{device: device_schema, snmp_device: snmp_device, interface: interface}
end
describe "changeset/2" do
test "valid with required fields", %{device: device} do
attrs = %{
device_id: device.id,
mac_address: "00:11:22:33:44:55",
last_seen_at: DateTime.utc_now()
}
changeset = MacAddress.changeset(%MacAddress{}, attrs)
assert changeset.valid?
end
test "valid with all fields", %{device: device, interface: interface} do
attrs = %{
device_id: device.id,
interface_id: interface.id,
mac_address: "00:11:22:33:44:55",
vlan_id: 100,
port_index: 1,
entry_status: "learned",
last_seen_at: DateTime.utc_now()
}
changeset = MacAddress.changeset(%MacAddress{}, attrs)
assert changeset.valid?
end
test "invalid without device_id" do
attrs = %{
mac_address: "00:11:22:33:44:55",
last_seen_at: DateTime.utc_now()
}
changeset = MacAddress.changeset(%MacAddress{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).device_id
end
test "invalid without mac_address", %{device: device} do
attrs = %{
device_id: device.id,
last_seen_at: DateTime.utc_now()
}
changeset = MacAddress.changeset(%MacAddress{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).mac_address
end
test "invalid without last_seen_at", %{device: device} do
attrs = %{
device_id: device.id,
mac_address: "00:11:22:33:44:55"
}
changeset = MacAddress.changeset(%MacAddress{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).last_seen_at
end
test "enforces unique constraint on device_id, mac_address, vlan_id", %{device: device} do
now = DateTime.truncate(DateTime.utc_now(), :second)
{:ok, _} =
%MacAddress{}
|> MacAddress.changeset(%{
device_id: device.id,
mac_address: "00:11:22:33:44:55",
vlan_id: 100,
last_seen_at: now
})
|> Repo.insert()
{:error, changeset} =
%MacAddress{}
|> MacAddress.changeset(%{
device_id: device.id,
mac_address: "00:11:22:33:44:55",
vlan_id: 100,
last_seen_at: now
})
|> Repo.insert()
assert "has already been taken" in errors_on(changeset)[:device_id]
end
test "allows same mac_address on different VLANs", %{device: device} do
now = DateTime.truncate(DateTime.utc_now(), :second)
{:ok, _} =
%MacAddress{}
|> MacAddress.changeset(%{
device_id: device.id,
mac_address: "00:11:22:33:44:55",
vlan_id: 100,
last_seen_at: now
})
|> Repo.insert()
{:ok, _} =
%MacAddress{}
|> MacAddress.changeset(%{
device_id: device.id,
mac_address: "00:11:22:33:44:55",
vlan_id: 200,
last_seen_at: now
})
|> Repo.insert()
end
end
end

View file

@ -0,0 +1,184 @@
defmodule Towerops.Snmp.MacDiscoveryTest do
use ExUnit.Case, async: true
import Mox
alias Towerops.Snmp.MacDiscovery
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
describe "discover_mac_table/1" do
test "returns empty list when no MAC entries found" do
# Q-BRIDGE port returns empty
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ ->
{:ok, []}
end)
# Q-BRIDGE status walk (still called even with empty port results)
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ ->
{:ok, []}
end)
# BRIDGE-MIB address returns empty
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.1", _ ->
{:ok, []}
end)
# BRIDGE-MIB port returns empty
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.2", _ ->
{:ok, []}
end)
# BRIDGE-MIB status returns empty
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.3", _ ->
{:ok, []}
end)
assert {:ok, []} = MacDiscovery.discover_mac_table(client_opts())
end
test "discovers Q-BRIDGE MAC entries with VLAN awareness" do
# Q-BRIDGE-MIB dot1qTpFdbPort walk
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ ->
{:ok,
[
# VLAN 100, MAC 00:11:22:33:44:55, Port 1
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.100.0.17.34.51.68.85", value: 1},
# VLAN 100, MAC 00:11:22:33:44:66, Port 2
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.100.0.17.34.51.68.102", value: 2},
# VLAN 200, MAC 00:11:22:33:44:55, Port 3
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.200.0.17.34.51.68.85", value: 3}
]}
end)
# Q-BRIDGE-MIB dot1qTpFdbStatus walk
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.100.0.17.34.51.68.85", value: 3},
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.100.0.17.34.51.68.102", value: 3},
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.200.0.17.34.51.68.85", value: 3}
]}
end)
{:ok, entries} = MacDiscovery.discover_mac_table(client_opts())
assert length(entries) == 3
entry1 = Enum.find(entries, &(&1.vlan_id == 100 && &1.port_index == 1))
assert entry1.mac_address == "00:11:22:33:44:55"
assert entry1.entry_status == "learned"
entry2 = Enum.find(entries, &(&1.vlan_id == 100 && &1.port_index == 2))
assert entry2.mac_address == "00:11:22:33:44:66"
entry3 = Enum.find(entries, &(&1.vlan_id == 200 && &1.port_index == 3))
assert entry3.mac_address == "00:11:22:33:44:55"
end
test "falls back to BRIDGE-MIB when Q-BRIDGE returns no entries" do
# Q-BRIDGE port returns empty
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ ->
{:ok, []}
end)
# Q-BRIDGE status walk (still called even with empty port results)
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ ->
{:ok, []}
end)
# BRIDGE-MIB dot1dTpFdbAddress walk
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.1", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.17.4.3.1.1.0.17.34.51.68.85", value: <<0, 17, 34, 51, 68, 85>>}
]}
end)
# BRIDGE-MIB dot1dTpFdbPort walk
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.2", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.17.4.3.1.2.0.17.34.51.68.85", value: 1}
]}
end)
# BRIDGE-MIB dot1dTpFdbStatus walk
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.3", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.17.4.3.1.3.0.17.34.51.68.85", value: 3}
]}
end)
{:ok, entries} = MacDiscovery.discover_mac_table(client_opts())
assert length(entries) == 1
entry = hd(entries)
assert entry.mac_address == "00:11:22:33:44:55"
assert entry.port_index == 1
assert entry.vlan_id == nil
assert entry.entry_status == "learned"
end
test "handles all FDB status values" do
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.1", value: 1},
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.2", value: 2},
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.3", value: 3},
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.4", value: 4},
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.5", value: 5}
]}
end)
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.1", value: 1},
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.2", value: 2},
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.3", value: 3},
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.4", value: 4},
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.5", value: 5}
]}
end)
{:ok, entries} = MacDiscovery.discover_mac_table(client_opts())
assert length(entries) == 5
statuses = entries |> Enum.map(& &1.entry_status) |> Enum.sort()
assert statuses == ["invalid", "learned", "mgmt", "other", "self"]
end
test "includes last_seen_at timestamp" do
now = DateTime.utc_now()
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.100.0.17.34.51.68.85", value: 1}
]}
end)
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ ->
{:ok, []}
end)
{:ok, entries} = MacDiscovery.discover_mac_table(client_opts())
assert length(entries) == 1
[entry] = entries
assert %DateTime{} = entry.last_seen_at
# Timestamp should be within 1 second of now
assert DateTime.diff(entry.last_seen_at, now, :second) <= 1
end
end
defp client_opts do
[ip: "192.168.1.1", community: "public", version: :v2c, port: 161]
end
end