diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index 13d7a815..4af1a09e 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -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 diff --git a/lib/towerops/snmp/mac_address.ex b/lib/towerops/snmp/mac_address.ex new file mode 100644 index 00000000..e69eeab0 --- /dev/null +++ b/lib/towerops/snmp/mac_address.ex @@ -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 diff --git a/lib/towerops/snmp/mac_discovery.ex b/lib/towerops/snmp/mac_discovery.ex new file mode 100644 index 00000000..606be6ed --- /dev/null +++ b/lib/towerops/snmp/mac_discovery.ex @@ -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 diff --git a/lib/towerops/snmp/neighbor_cleanup_worker.ex b/lib/towerops/snmp/neighbor_cleanup_worker.ex index 6b6364d9..e147ac4c 100644 --- a/lib/towerops/snmp/neighbor_cleanup_worker.ex +++ b/lib/towerops/snmp/neighbor_cleanup_worker.ex @@ -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 diff --git a/lib/towerops/snmp/poller_worker.ex b/lib/towerops/snmp/poller_worker.ex index ed5df33c..0daa32d2 100644 --- a/lib/towerops/snmp/poller_worker.ex +++ b/lib/towerops/snmp/poller_worker.ex @@ -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 || [] diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index d76f485e..25f3191b 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -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 diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index a5c470b2..d2822441 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -30,6 +30,35 @@
{@device.ip_address}
+ +| MAC Address | +VLAN | +Interface | +Status | +Last Seen | +
|---|---|---|---|---|
| + {mac.mac_address} + | ++ <%= if mac.vlan_id do %> + {mac.vlan_id} + <% else %> + - + <% end %> + | ++ <%= 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} + + <% else %> + Port {mac.port_index} + <% end %> + | ++ + {mac.entry_status || "unknown"} + + | ++ {time_ago(mac.last_seen_at)} + | +
+ This device doesn't have any MAC forwarding entries, or MAC discovery hasn't run yet. +
+