updates
This commit is contained in:
parent
6908661bd1
commit
3539e36f12
14 changed files with 857 additions and 413 deletions
|
|
@ -68,7 +68,7 @@ const SensorChart: SensorChartHook = {
|
|||
const unit = this.el.dataset.unit || '%'
|
||||
const autoScale = this.el.dataset.autoScale === 'true' || this.el.dataset.autoScale === 'true'
|
||||
const showZeroLine = this.el.dataset.showZeroLine === 'true' || this.el.dataset.showZeroLine === 'true'
|
||||
const chartType = unit === 'bps' ? 'bar' : 'line'
|
||||
const isTrafficChart = unit === 'bps'
|
||||
|
||||
// Generate colors for each dataset
|
||||
const colors = [
|
||||
|
|
@ -83,18 +83,20 @@ const SensorChart: SensorChartHook = {
|
|||
]
|
||||
|
||||
const datasets: ChartDataset[] = data.datasets.map((dataset, index) => {
|
||||
const color = colors[index % colors.length]
|
||||
const baseConfig: ChartDataset = {
|
||||
...dataset,
|
||||
borderColor: colors[index % colors.length],
|
||||
backgroundColor: colors[index % colors.length].replace('rgb', 'rgba').replace(')', ', 0.5)'),
|
||||
borderWidth: chartType === 'bar' ? 0 : 2,
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.2)'),
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
}
|
||||
|
||||
// Add line-specific properties
|
||||
if (chartType === 'line') {
|
||||
baseConfig.tension = 0.4
|
||||
baseConfig.pointRadius = 0
|
||||
baseConfig.pointHoverRadius = 4
|
||||
// Add fill for traffic charts to create area effect
|
||||
if (isTrafficChart) {
|
||||
baseConfig.fill = 'origin' // Fill to the zero line
|
||||
}
|
||||
|
||||
return baseConfig
|
||||
|
|
@ -210,14 +212,11 @@ const SensorChart: SensorChartHook = {
|
|||
const twentyFourHoursAgo = now - (24 * 60 * 60 * 1000)
|
||||
|
||||
this.chart = new Chart(ctx, {
|
||||
type: chartType,
|
||||
type: 'line',
|
||||
data: { datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
// Make bars connect with no gaps for traffic charts
|
||||
barPercentage: chartType === 'bar' ? 1.0 : undefined,
|
||||
categoryPercentage: chartType === 'bar' ? 1.0 : undefined,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
|
|
|
|||
|
|
@ -114,6 +114,11 @@ defmodule Towerops.Agent.Metric do
|
|||
type: Towerops.Agent.InterfaceStat,
|
||||
json_name: "interfaceStat",
|
||||
oneof: 0
|
||||
|
||||
field :neighbor_discovery, 3,
|
||||
type: Towerops.Agent.NeighborDiscovery,
|
||||
json_name: "neighborDiscovery",
|
||||
oneof: 0
|
||||
end
|
||||
|
||||
defmodule Towerops.Agent.SensorReading do
|
||||
|
|
@ -148,6 +153,27 @@ defmodule Towerops.Agent.InterfaceStat do
|
|||
field :timestamp, 8, type: :int64
|
||||
end
|
||||
|
||||
defmodule Towerops.Agent.NeighborDiscovery do
|
||||
@moduledoc false
|
||||
|
||||
use Protobuf,
|
||||
full_name: "towerops.agent.NeighborDiscovery",
|
||||
protoc_gen_elixir_version: "0.16.0",
|
||||
syntax: :proto3
|
||||
|
||||
field :interface_id, 1, type: :string, json_name: "interfaceId"
|
||||
field :protocol, 2, type: :string
|
||||
field :remote_chassis_id, 3, type: :string, json_name: "remoteChassisId"
|
||||
field :remote_system_name, 4, type: :string, json_name: "remoteSystemName"
|
||||
field :remote_system_description, 5, type: :string, json_name: "remoteSystemDescription"
|
||||
field :remote_platform, 6, type: :string, json_name: "remotePlatform"
|
||||
field :remote_port_id, 7, type: :string, json_name: "remotePortId"
|
||||
field :remote_port_description, 8, type: :string, json_name: "remotePortDescription"
|
||||
field :remote_address, 9, type: :string, json_name: "remoteAddress"
|
||||
field :remote_capabilities, 10, repeated: true, type: :string, json_name: "remoteCapabilities"
|
||||
field :timestamp, 11, type: :int64
|
||||
end
|
||||
|
||||
defmodule Towerops.Agent.HeartbeatMetadata do
|
||||
@moduledoc false
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ defmodule Towerops.Snmp do
|
|||
alias Towerops.Snmp.Discovery
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.InterfaceStat
|
||||
alias Towerops.Snmp.Neighbor
|
||||
alias Towerops.Snmp.Sensor
|
||||
alias Towerops.Snmp.SensorReading
|
||||
|
||||
|
|
@ -343,4 +344,55 @@ defmodule Towerops.Snmp do
|
|||
|> InterfaceStat.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
# Neighbor queries
|
||||
|
||||
@doc """
|
||||
Lists all neighbors for a piece of equipment.
|
||||
"""
|
||||
def list_neighbors(equipment_id) do
|
||||
Neighbor
|
||||
|> where([n], n.equipment_id == ^equipment_id)
|
||||
|> preload(:interface)
|
||||
|> order_by([n], [n.protocol, n.remote_system_name])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a specific neighbor.
|
||||
"""
|
||||
def get_neighbor(neighbor_id) do
|
||||
Repo.get(Neighbor, neighbor_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates or updates a neighbor record.
|
||||
"""
|
||||
def upsert_neighbor(attrs) do
|
||||
case Repo.get_by(Neighbor,
|
||||
interface_id: attrs.interface_id,
|
||||
remote_chassis_id: attrs.remote_chassis_id,
|
||||
protocol: attrs.protocol
|
||||
) do
|
||||
nil ->
|
||||
%Neighbor{}
|
||||
|> Neighbor.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|
||||
neighbor ->
|
||||
neighbor
|
||||
|> Neighbor.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes stale neighbors that haven't been seen since the given datetime.
|
||||
"""
|
||||
def delete_stale_neighbors(equipment_id, cutoff_datetime) do
|
||||
Neighbor
|
||||
|> where([n], n.equipment_id == ^equipment_id)
|
||||
|> where([n], n.last_discovered_at < ^cutoff_datetime)
|
||||
|> Repo.delete_all()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ defmodule Towerops.Snmp.Discovery do
|
|||
alias Towerops.Snmp.Client
|
||||
alias Towerops.Snmp.Device
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.NeighborDiscovery
|
||||
alias Towerops.Snmp.Profiles.Base
|
||||
alias Towerops.Snmp.Profiles.Cisco
|
||||
alias Towerops.Snmp.Profiles.Mikrotik
|
||||
|
|
@ -104,7 +105,9 @@ defmodule Towerops.Snmp.Discovery do
|
|||
{:ok, device_info} <- build_device_info(client_opts, system_info, profile),
|
||||
{:ok, interfaces} <- discover_interfaces(client_opts, profile),
|
||||
{:ok, sensors} <- discover_sensors(client_opts, profile),
|
||||
{:ok, device} <- save_discovery_results(equipment, device_info, interfaces, sensors) do
|
||||
{:ok, device} <- save_discovery_results(equipment, device_info, interfaces, sensors),
|
||||
{:ok, neighbors} <- NeighborDiscovery.discover_neighbors(client_opts, device.interfaces),
|
||||
:ok <- save_neighbors(equipment.id, neighbors) do
|
||||
update_equipment_discovery_time(equipment)
|
||||
Logger.info("SNMP discovery completed successfully for: #{equipment.name}")
|
||||
|
||||
|
|
@ -311,4 +314,19 @@ defmodule Towerops.Snmp.Discovery do
|
|||
|> Ecto.Changeset.change(last_discovery_at: DateTime.truncate(DateTime.utc_now(), :second))
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@spec save_neighbors(binary(), [map()]) :: :ok
|
||||
defp save_neighbors(equipment_id, neighbors) do
|
||||
# Delete stale neighbors (not seen in last 5 minutes)
|
||||
cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
|
||||
Towerops.Snmp.delete_stale_neighbors(equipment_id, cutoff)
|
||||
|
||||
# Upsert each discovered neighbor
|
||||
Enum.each(neighbors, fn neighbor_data ->
|
||||
Towerops.Snmp.upsert_neighbor(neighbor_data)
|
||||
end)
|
||||
|
||||
Logger.info("Saved #{length(neighbors)} neighbors for equipment #{equipment_id}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
|
|
|||
55
lib/towerops/snmp/neighbor.ex
Normal file
55
lib/towerops/snmp/neighbor.ex
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
defmodule Towerops.Snmp.Neighbor do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "snmp_neighbors" do
|
||||
field :protocol, :string
|
||||
field :remote_chassis_id, :string
|
||||
field :remote_system_name, :string
|
||||
field :remote_system_description, :string
|
||||
field :remote_platform, :string
|
||||
field :remote_port_id, :string
|
||||
field :remote_port_description, :string
|
||||
field :remote_address, :string
|
||||
field :remote_capabilities, {:array, :string}, default: []
|
||||
field :last_discovered_at, :utc_datetime
|
||||
|
||||
belongs_to :equipment, Towerops.Equipment.Equipment
|
||||
belongs_to :interface, Towerops.Snmp.Interface
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(neighbor, attrs) do
|
||||
neighbor
|
||||
|> cast(attrs, [
|
||||
:equipment_id,
|
||||
:interface_id,
|
||||
:protocol,
|
||||
:remote_chassis_id,
|
||||
:remote_system_name,
|
||||
:remote_system_description,
|
||||
:remote_platform,
|
||||
:remote_port_id,
|
||||
:remote_port_description,
|
||||
:remote_address,
|
||||
:remote_capabilities,
|
||||
:last_discovered_at
|
||||
])
|
||||
|> validate_required([
|
||||
:equipment_id,
|
||||
:interface_id,
|
||||
:protocol,
|
||||
:last_discovered_at
|
||||
])
|
||||
|> validate_inclusion(:protocol, ["lldp", "cdp"])
|
||||
|> foreign_key_constraint(:equipment_id)
|
||||
|> foreign_key_constraint(:interface_id)
|
||||
|> unique_constraint([:interface_id, :remote_chassis_id, :protocol])
|
||||
end
|
||||
end
|
||||
301
lib/towerops/snmp/neighbor_discovery.ex
Normal file
301
lib/towerops/snmp/neighbor_discovery.ex
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
defmodule Towerops.Snmp.NeighborDiscovery do
|
||||
@moduledoc """
|
||||
Discovers network neighbors via LLDP and CDP protocols.
|
||||
|
||||
Supports:
|
||||
- LLDP (Link Layer Discovery Protocol) via LLDP-MIB
|
||||
- CDP (Cisco Discovery Protocol) via CISCO-CDP-MIB
|
||||
"""
|
||||
|
||||
alias Towerops.Snmp.Client
|
||||
|
||||
require Logger
|
||||
|
||||
@type neighbor_data :: %{
|
||||
required(:protocol) => String.t(),
|
||||
required(:interface_id) => binary(),
|
||||
required(:equipment_id) => binary(),
|
||||
optional(:remote_chassis_id) => String.t(),
|
||||
optional(:remote_system_name) => String.t(),
|
||||
optional(:remote_system_description) => String.t(),
|
||||
optional(:remote_platform) => String.t(),
|
||||
optional(:remote_port_id) => String.t(),
|
||||
optional(:remote_port_description) => String.t(),
|
||||
optional(:remote_address) => String.t(),
|
||||
optional(:remote_capabilities) => [String.t()],
|
||||
required(:last_discovered_at) => DateTime.t()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Discovers all neighbors (LLDP and CDP) for a device.
|
||||
Returns {:ok, neighbors} where neighbors is a list of neighbor_data maps.
|
||||
"""
|
||||
@spec discover_neighbors(Client.connection_opts(), list(map())) ::
|
||||
{:ok, [neighbor_data()]}
|
||||
def discover_neighbors(client_opts, interfaces) do
|
||||
lldp_neighbors = discover_lldp_neighbors(client_opts, interfaces)
|
||||
cdp_neighbors = discover_cdp_neighbors(client_opts, interfaces)
|
||||
|
||||
{:ok, lldp_neighbors ++ cdp_neighbors}
|
||||
end
|
||||
|
||||
# LLDP Discovery (IEEE 802.1AB)
|
||||
|
||||
@lldp_rem_table_oid "1.0.8802.1.1.2.1.4.1.1"
|
||||
|
||||
defp discover_lldp_neighbors(client_opts, interfaces) do
|
||||
case Client.walk(client_opts, @lldp_rem_table_oid) do
|
||||
{:ok, entries} when entries != [] ->
|
||||
parse_lldp_neighbors(entries, interfaces)
|
||||
|
||||
{:ok, []} ->
|
||||
Logger.debug("No LLDP neighbors found")
|
||||
[]
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.debug("LLDP discovery failed: #{inspect(reason)}")
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_lldp_neighbors(entries, interfaces) do
|
||||
# Group by local port index (time, local_port_num, rem_index)
|
||||
# OIDs are in format: 1.0.8802.1.1.2.1.4.1.1.X.time.local_port.rem_index
|
||||
grouped =
|
||||
Enum.group_by(entries, fn %{oid: oid} ->
|
||||
# Extract local port num (second to last index) and remote index (last index)
|
||||
parts = String.split(oid, ".")
|
||||
[rem_index, local_port, _time | _] = Enum.reverse(parts)
|
||||
"#{local_port}.#{rem_index}"
|
||||
end)
|
||||
|
||||
grouped
|
||||
|> Enum.map(fn {_key, neighbor_entries} ->
|
||||
build_lldp_neighbor(neighbor_entries, interfaces)
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
defp build_lldp_neighbor(entries, interfaces) do
|
||||
neighbor_map =
|
||||
Enum.reduce(entries, %{}, fn %{oid: oid, value: value}, acc ->
|
||||
parts = String.split(oid, ".")
|
||||
field_oid = Enum.at(parts, 11)
|
||||
parse_lldp_field(field_oid, value, oid, acc)
|
||||
end)
|
||||
|
||||
build_neighbor_record("lldp", neighbor_map, interfaces, neighbor_map[:local_port])
|
||||
end
|
||||
|
||||
defp parse_lldp_field("4", value, _oid, acc), do: Map.put(acc, :remote_chassis_id, format_chassis_id(value))
|
||||
|
||||
defp parse_lldp_field("5", value, _oid, acc), do: Map.put(acc, :remote_port_id, format_port_id(value))
|
||||
|
||||
defp parse_lldp_field("6", value, _oid, acc), do: Map.put(acc, :remote_port_description, to_string_safe(value))
|
||||
|
||||
defp parse_lldp_field("7", value, _oid, acc), do: Map.put(acc, :remote_system_name, to_string_safe(value))
|
||||
|
||||
defp parse_lldp_field("8", value, _oid, acc), do: Map.put(acc, :remote_system_description, to_string_safe(value))
|
||||
|
||||
defp parse_lldp_field("12", value, _oid, acc), do: Map.put(acc, :remote_capabilities, parse_lldp_capabilities(value))
|
||||
|
||||
defp parse_lldp_field(_field_oid, _value, oid, acc), do: extract_local_port(acc, oid)
|
||||
|
||||
defp extract_local_port(acc, oid) do
|
||||
parts = String.split(oid, ".")
|
||||
[_rem_index, local_port | _] = Enum.reverse(parts)
|
||||
Map.put(acc, :local_port, String.to_integer(local_port))
|
||||
end
|
||||
|
||||
defp build_neighbor_record(_protocol, _neighbor_map, _interfaces, nil), do: nil
|
||||
|
||||
defp build_neighbor_record(protocol, neighbor_map, interfaces, if_index) do
|
||||
case find_interface_by_index(if_index, interfaces) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
interface ->
|
||||
%{
|
||||
protocol: protocol,
|
||||
interface_id: interface.id,
|
||||
equipment_id: interface.equipment_id,
|
||||
remote_chassis_id: neighbor_map[:remote_chassis_id],
|
||||
remote_system_name: neighbor_map[:remote_system_name],
|
||||
remote_system_description: neighbor_map[:remote_system_description],
|
||||
remote_platform: neighbor_map[:remote_platform],
|
||||
remote_port_id: neighbor_map[:remote_port_id],
|
||||
remote_port_description: neighbor_map[:remote_port_description],
|
||||
remote_address: neighbor_map[:remote_address],
|
||||
remote_capabilities: neighbor_map[:remote_capabilities] || [],
|
||||
last_discovered_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# CDP Discovery (Cisco proprietary)
|
||||
|
||||
@cdp_cache_table_oid "1.3.6.1.4.1.9.9.23.1.2.1.1"
|
||||
|
||||
defp discover_cdp_neighbors(client_opts, interfaces) do
|
||||
case Client.walk(client_opts, @cdp_cache_table_oid) do
|
||||
{:ok, entries} when entries != [] ->
|
||||
parse_cdp_neighbors(entries, interfaces)
|
||||
|
||||
{:ok, []} ->
|
||||
Logger.debug("No CDP neighbors found")
|
||||
[]
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.debug("CDP discovery failed: #{inspect(reason)}")
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_cdp_neighbors(entries, interfaces) do
|
||||
# Group by interface index and device index
|
||||
# OIDs are in format: 1.3.6.1.4.1.9.9.23.1.2.1.1.X.if_index.device_index
|
||||
grouped =
|
||||
Enum.group_by(entries, fn %{oid: oid} ->
|
||||
parts = String.split(oid, ".")
|
||||
# Get last two parts (interface index and device index)
|
||||
[device_idx | rest] = Enum.reverse(parts)
|
||||
[if_idx | _] = rest
|
||||
"#{if_idx}.#{device_idx}"
|
||||
end)
|
||||
|
||||
grouped
|
||||
|> Enum.map(fn {_key, neighbor_entries} ->
|
||||
build_cdp_neighbor(neighbor_entries, interfaces)
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
defp build_cdp_neighbor(entries, interfaces) do
|
||||
neighbor_map =
|
||||
Enum.reduce(entries, %{}, fn %{oid: oid, value: value}, acc ->
|
||||
parts = String.split(oid, ".")
|
||||
field_oid = Enum.at(parts, 12)
|
||||
parse_cdp_field(field_oid, value, oid, acc)
|
||||
end)
|
||||
|
||||
build_neighbor_record("cdp", neighbor_map, interfaces, neighbor_map[:local_if_index])
|
||||
end
|
||||
|
||||
defp parse_cdp_field("4", value, _oid, acc), do: Map.put(acc, :remote_address, format_ip_address(value))
|
||||
|
||||
defp parse_cdp_field("6", value, _oid, acc), do: Map.put(acc, :remote_system_name, to_string_safe(value))
|
||||
|
||||
defp parse_cdp_field("7", value, _oid, acc), do: Map.put(acc, :remote_port_id, to_string_safe(value))
|
||||
|
||||
defp parse_cdp_field("8", value, _oid, acc), do: Map.put(acc, :remote_platform, to_string_safe(value))
|
||||
|
||||
defp parse_cdp_field("9", value, _oid, acc), do: Map.put(acc, :remote_capabilities, parse_cdp_capabilities(value))
|
||||
|
||||
defp parse_cdp_field(_field_oid, _value, oid, acc), do: extract_cdp_local_interface(acc, oid)
|
||||
|
||||
defp extract_cdp_local_interface(acc, oid) do
|
||||
parts = String.split(oid, ".")
|
||||
[_device_idx, if_idx | _] = Enum.reverse(parts)
|
||||
Map.put(acc, :local_if_index, String.to_integer(if_idx))
|
||||
end
|
||||
|
||||
# Helper Functions
|
||||
|
||||
defp find_interface_by_index(nil, _interfaces), do: nil
|
||||
|
||||
defp find_interface_by_index(if_index, interfaces) do
|
||||
Enum.find(interfaces, fn interface ->
|
||||
interface.if_index == if_index
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_chassis_id(value) when is_binary(value) do
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defp format_chassis_id(value), do: to_string_safe(value)
|
||||
|
||||
defp format_port_id(value) when is_binary(value) do
|
||||
# Try to convert to string first, if it fails use MAC format
|
||||
if String.valid?(value) do
|
||||
value
|
||||
else
|
||||
format_chassis_id(value)
|
||||
end
|
||||
end
|
||||
|
||||
defp format_port_id(value), do: to_string_safe(value)
|
||||
|
||||
defp format_ip_address(value) when is_binary(value) and byte_size(value) == 4 do
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.join(".")
|
||||
end
|
||||
|
||||
defp format_ip_address(value), do: to_string_safe(value)
|
||||
|
||||
defp to_string_safe(value) when is_binary(value), do: String.trim(value)
|
||||
defp to_string_safe(value) when is_integer(value), do: Integer.to_string(value)
|
||||
defp to_string_safe(value), do: inspect(value)
|
||||
|
||||
# LLDP capabilities bitmap (from LLDP-MIB)
|
||||
@lldp_cap_other 0x01
|
||||
@lldp_cap_repeater 0x02
|
||||
@lldp_cap_bridge 0x04
|
||||
@lldp_cap_wlan_ap 0x08
|
||||
@lldp_cap_router 0x10
|
||||
@lldp_cap_telephone 0x20
|
||||
@lldp_cap_docsis 0x40
|
||||
@lldp_cap_station 0x80
|
||||
|
||||
defp parse_lldp_capabilities(value) when is_binary(value) do
|
||||
<<caps::integer-size(16)>> = value
|
||||
|
||||
[]
|
||||
|> maybe_add_cap(caps, @lldp_cap_other, "other")
|
||||
|> maybe_add_cap(caps, @lldp_cap_repeater, "repeater")
|
||||
|> maybe_add_cap(caps, @lldp_cap_bridge, "bridge")
|
||||
|> maybe_add_cap(caps, @lldp_cap_wlan_ap, "wlan-ap")
|
||||
|> maybe_add_cap(caps, @lldp_cap_router, "router")
|
||||
|> maybe_add_cap(caps, @lldp_cap_telephone, "telephone")
|
||||
|> maybe_add_cap(caps, @lldp_cap_docsis, "docsis")
|
||||
|> maybe_add_cap(caps, @lldp_cap_station, "station")
|
||||
end
|
||||
|
||||
defp parse_lldp_capabilities(_value), do: []
|
||||
|
||||
# CDP capabilities bitmap (from CISCO-CDP-MIB)
|
||||
@cdp_cap_router 0x01
|
||||
@cdp_cap_trans_bridge 0x02
|
||||
@cdp_cap_source_bridge 0x04
|
||||
@cdp_cap_switch 0x08
|
||||
@cdp_cap_host 0x10
|
||||
@cdp_cap_igmp 0x20
|
||||
@cdp_cap_repeater 0x40
|
||||
|
||||
defp parse_cdp_capabilities(value) when is_binary(value) and byte_size(value) == 1 do
|
||||
<<caps::integer-size(8)>> = value
|
||||
|
||||
[]
|
||||
|> maybe_add_cap(caps, @cdp_cap_router, "router")
|
||||
|> maybe_add_cap(caps, @cdp_cap_trans_bridge, "trans-bridge")
|
||||
|> maybe_add_cap(caps, @cdp_cap_source_bridge, "source-bridge")
|
||||
|> maybe_add_cap(caps, @cdp_cap_switch, "switch")
|
||||
|> maybe_add_cap(caps, @cdp_cap_host, "host")
|
||||
|> maybe_add_cap(caps, @cdp_cap_igmp, "igmp")
|
||||
|> maybe_add_cap(caps, @cdp_cap_repeater, "repeater")
|
||||
end
|
||||
|
||||
defp parse_cdp_capabilities(_value), do: []
|
||||
|
||||
defp maybe_add_cap(list, caps, bit_mask, name) do
|
||||
if Bitwise.band(caps, bit_mask) == 0 do
|
||||
list
|
||||
else
|
||||
[name | list]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -228,32 +228,58 @@ defmodule ToweropsWeb.Api.AgentController do
|
|||
end
|
||||
|
||||
defp process_metrics(_agent_token, metrics) do
|
||||
Enum.each(metrics, fn metric ->
|
||||
case metric do
|
||||
%{"type" => "sensor_reading"} = m ->
|
||||
Snmp.create_sensor_reading(%{
|
||||
sensor_id: m["sensor_id"],
|
||||
value: m["value"],
|
||||
status: m["status"] || "ok",
|
||||
checked_at: parse_timestamp(m["timestamp"])
|
||||
})
|
||||
Enum.each(metrics, &process_single_metric/1)
|
||||
end
|
||||
|
||||
%{"type" => "interface_stat"} = m ->
|
||||
Snmp.create_interface_stat(%{
|
||||
interface_id: m["interface_id"],
|
||||
if_in_octets: m["if_in_octets"],
|
||||
if_out_octets: m["if_out_octets"],
|
||||
if_in_errors: m["if_in_errors"],
|
||||
if_out_errors: m["if_out_errors"],
|
||||
if_in_discards: m["if_in_discards"],
|
||||
if_out_discards: m["if_out_discards"],
|
||||
checked_at: parse_timestamp(m["timestamp"])
|
||||
})
|
||||
defp process_single_metric(%{"type" => "sensor_reading"} = m) do
|
||||
Snmp.create_sensor_reading(%{
|
||||
sensor_id: m["sensor_id"],
|
||||
value: m["value"],
|
||||
status: m["status"] || "ok",
|
||||
checked_at: parse_timestamp(m["timestamp"])
|
||||
})
|
||||
end
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end)
|
||||
defp process_single_metric(%{"type" => "interface_stat"} = m) do
|
||||
Snmp.create_interface_stat(%{
|
||||
interface_id: m["interface_id"],
|
||||
if_in_octets: m["if_in_octets"],
|
||||
if_out_octets: m["if_out_octets"],
|
||||
if_in_errors: m["if_in_errors"],
|
||||
if_out_errors: m["if_out_errors"],
|
||||
if_in_discards: m["if_in_discards"],
|
||||
if_out_discards: m["if_out_discards"],
|
||||
checked_at: parse_timestamp(m["timestamp"])
|
||||
})
|
||||
end
|
||||
|
||||
defp process_single_metric(%{"type" => "neighbor_discovery"} = m) do
|
||||
process_neighbor_discovery(m)
|
||||
end
|
||||
|
||||
defp process_single_metric(_), do: :ok
|
||||
|
||||
defp process_neighbor_discovery(m) do
|
||||
case Snmp.get_interface(m["interface_id"]) do
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
interface ->
|
||||
Snmp.upsert_neighbor(%{
|
||||
interface_id: m["interface_id"],
|
||||
equipment_id: interface.equipment_id,
|
||||
protocol: m["protocol"],
|
||||
remote_chassis_id: m["remote_chassis_id"],
|
||||
remote_system_name: m["remote_system_name"],
|
||||
remote_system_description: m["remote_system_description"],
|
||||
remote_platform: m["remote_platform"],
|
||||
remote_port_id: m["remote_port_id"],
|
||||
remote_port_description: m["remote_port_description"],
|
||||
remote_address: m["remote_address"],
|
||||
remote_capabilities: m["remote_capabilities"] || [],
|
||||
last_discovered_at: parse_timestamp(m["timestamp"])
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
defp convert_protobuf_metrics(metrics) do
|
||||
|
|
@ -282,6 +308,22 @@ defmodule ToweropsWeb.Api.AgentController do
|
|||
"timestamp" => is.timestamp
|
||||
}
|
||||
|
||||
{:neighbor_discovery, nd} ->
|
||||
%{
|
||||
"type" => "neighbor_discovery",
|
||||
"interface_id" => nd.interface_id,
|
||||
"protocol" => nd.protocol,
|
||||
"remote_chassis_id" => nd.remote_chassis_id,
|
||||
"remote_system_name" => nd.remote_system_name,
|
||||
"remote_system_description" => nd.remote_system_description,
|
||||
"remote_platform" => nd.remote_platform,
|
||||
"remote_port_id" => nd.remote_port_id,
|
||||
"remote_port_description" => nd.remote_port_description,
|
||||
"remote_address" => nd.remote_address,
|
||||
"remote_capabilities" => nd.remote_capabilities,
|
||||
"timestamp" => nd.timestamp
|
||||
}
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -283,15 +283,22 @@ defmodule ToweropsWeb.Api.MobileController do
|
|||
defp format_alert(alert) do
|
||||
%{
|
||||
id: alert.id,
|
||||
severity: alert.severity,
|
||||
status: alert.status,
|
||||
status: format_alert_status(alert),
|
||||
message: alert.message,
|
||||
equipment_name: alert.equipment && alert.equipment.name,
|
||||
equipment_id: alert.equipment_id,
|
||||
occurred_at: alert.occurred_at
|
||||
occurred_at: alert.triggered_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_alert_status(alert) do
|
||||
cond do
|
||||
alert.resolved_at != nil -> "resolved"
|
||||
alert.acknowledged_at != nil -> "acknowledged"
|
||||
true -> "active"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_uptime(equipment) do
|
||||
if equipment.snmp_device && equipment.snmp_device.sys_uptime do
|
||||
timeticks_to_string(equipment.snmp_device.sys_uptime)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ defmodule ToweropsWeb.EquipmentLive.Show do
|
|||
recent_checks = Monitoring.list_equipment_checks(equipment_id, 50)
|
||||
snmp_data = load_snmp_data(equipment_id)
|
||||
events = Equipment.list_equipment_events(equipment_id, 100)
|
||||
neighbors = Snmp.list_neighbors(equipment_id)
|
||||
cpu_chart_data = load_sensor_chart_data(equipment_id, ["cpu_load"])
|
||||
memory_chart_data = load_sensor_chart_data(equipment_id, ["memory_usage"])
|
||||
storage_chart_data = load_sensor_chart_data(equipment_id, ["disk_usage"])
|
||||
|
|
@ -76,6 +77,7 @@ defmodule ToweropsWeb.EquipmentLive.Show do
|
|||
|> assign(:snmp_device, snmp_data.device)
|
||||
|> assign(:snmp_interfaces, snmp_data.interfaces)
|
||||
|> assign(:snmp_sensors, snmp_data.sensors)
|
||||
|> assign(:neighbors, neighbors)
|
||||
|> assign(:events, events)
|
||||
|> assign(:cpu_chart_data, cpu_chart_data)
|
||||
|> assign(:memory_chart_data, memory_chart_data)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,20 @@
|
|||
</.link>
|
||||
<% end %>
|
||||
|
||||
<.link
|
||||
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=neighbors"}
|
||||
class={[
|
||||
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
||||
if @active_tab == "neighbors" do
|
||||
"border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
else
|
||||
"border-transparent text-zinc-500 hover:text-zinc-700 hover:border-zinc-300 dark:text-zinc-400 dark:hover:text-zinc-300"
|
||||
end
|
||||
]}
|
||||
>
|
||||
Neighbors
|
||||
</.link>
|
||||
|
||||
<.link
|
||||
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=logs"}
|
||||
class={[
|
||||
|
|
@ -82,30 +96,30 @@
|
|||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<dl class="space-y-3">
|
||||
<dl class="space-y-1">
|
||||
<%= if @snmp_device do %>
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">System Name</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{@snmp_device.sys_name || @equipment.name}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Resolved IP</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
|
||||
{@equipment.ip_address}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Hardware</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{@snmp_device.model || "N/A"}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Operating System</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{if @snmp_device.manufacturer && @snmp_device.firmware_version do
|
||||
|
|
@ -116,42 +130,42 @@
|
|||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Object ID</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
|
||||
{@snmp_device.sys_object_id || "N/A"}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Contact</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{@snmp_device.sys_contact || "N/A"}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Location</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{@snmp_device.sys_location || "N/A"}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Uptime</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{format_uptime(@snmp_device.sys_uptime)}
|
||||
</dd>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Name</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{@equipment.name}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">IP Address</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
|
||||
{@equipment.ip_address}
|
||||
|
|
@ -159,14 +173,14 @@
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Device Added</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{format_device_age(@equipment.inserted_at)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between py-2">
|
||||
<div class="flex justify-between py-1.5">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Last Discovered</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{if @equipment.last_discovery_at do
|
||||
|
|
@ -278,9 +292,9 @@
|
|||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<dl class="space-y-3">
|
||||
<dl class="space-y-1">
|
||||
<%= for sensor <- @storage_sensors do %>
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<.link
|
||||
navigate={
|
||||
|
|
@ -317,9 +331,9 @@
|
|||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<dl class="space-y-3">
|
||||
<dl class="space-y-1">
|
||||
<%= for sensor <- @temperature_sensors do %>
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<.link
|
||||
navigate={
|
||||
|
|
@ -354,9 +368,9 @@
|
|||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Voltage</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<dl class="space-y-3">
|
||||
<dl class="space-y-1">
|
||||
<%= for sensor <- @voltage_sensors do %>
|
||||
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<.link
|
||||
navigate={
|
||||
|
|
@ -413,14 +427,21 @@
|
|||
{interface.if_index}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{interface.if_name || interface.if_descr}
|
||||
</div>
|
||||
<%= if interface.if_alias do %>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{interface.if_alias}
|
||||
<.link
|
||||
navigate={
|
||||
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/traffic?interface_id=#{interface.id}"
|
||||
}
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
<div class="font-medium text-zinc-900 dark:text-zinc-100 hover:underline">
|
||||
{interface.if_name || interface.if_descr}
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if interface.if_alias do %>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{interface.if_alias}
|
||||
</div>
|
||||
<% end %>
|
||||
</.link>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class={[
|
||||
|
|
@ -455,6 +476,96 @@
|
|||
<p class="text-zinc-500 dark:text-zinc-400">No interfaces discovered</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% "neighbors" -> %>
|
||||
<%= if @neighbors && length(@neighbors) > 0 do %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Discovered Neighbors
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
|
||||
<thead class="bg-zinc-50 dark:bg-zinc-900">
|
||||
<tr class="text-xs text-zinc-600 dark:text-zinc-400">
|
||||
<th class="px-4 py-3 text-left font-medium">Protocol</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Local Interface</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Remote Device</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Remote Port</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Platform</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Capabilities</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
|
||||
<%= for neighbor <- @neighbors do %>
|
||||
<tr class="text-sm hover:bg-zinc-50 dark:hover:bg-zinc-900">
|
||||
<td class="px-4 py-3">
|
||||
<span class={[
|
||||
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium uppercase",
|
||||
neighbor.protocol == "lldp" &&
|
||||
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
neighbor.protocol == "cdp" &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
]}>
|
||||
{neighbor.protocol}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-900 dark:text-zinc-100">
|
||||
{neighbor.interface.if_name || neighbor.interface.if_descr}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{neighbor.remote_system_name || "Unknown"}
|
||||
</div>
|
||||
<%= if neighbor.remote_address do %>
|
||||
<div class="text-xs font-mono text-zinc-500 dark:text-zinc-400">
|
||||
{neighbor.remote_address}
|
||||
</div>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-zinc-900 dark:text-zinc-100">
|
||||
{neighbor.remote_port_id || "-"}
|
||||
</div>
|
||||
<%= if neighbor.remote_port_description do %>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{neighbor.remote_port_description}
|
||||
</div>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
{neighbor.remote_platform || "-"}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
<%= if neighbor.remote_capabilities && length(neighbor.remote_capabilities) > 0 do %>
|
||||
{Enum.join(neighbor.remote_capabilities, ", ")}
|
||||
<% else %>
|
||||
-
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{time_ago(neighbor.last_discovered_at)}
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 p-12">
|
||||
<div class="text-center">
|
||||
<.icon name="hero-server-stack" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
No neighbors discovered
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
This device doesn't have any LLDP or CDP neighbors, or neighbor discovery hasn't run yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% "logs" -> %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||
|
|
|
|||
|
|
@ -17,12 +17,14 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
end
|
||||
|
||||
range = Map.get(params, "range", "24h")
|
||||
interface_id = Map.get(params, "interface_id")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:equipment_id, equipment_id)
|
||||
|> assign(:sensor_type, sensor_type)
|
||||
|> assign(:range, range)
|
||||
|> assign(:interface_id, interface_id)
|
||||
|> load_graph_data()
|
||||
|
||||
{:noreply, socket}
|
||||
|
|
@ -30,37 +32,56 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
|
||||
@impl true
|
||||
def handle_event("change_range", %{"range" => range}, socket) do
|
||||
params = build_graph_params(socket.assigns, range)
|
||||
|
||||
{:noreply,
|
||||
push_patch(socket,
|
||||
to:
|
||||
~p"/orgs/#{socket.assigns.current_organization.slug}/equipment/#{socket.assigns.equipment_id}/graph/#{socket.assigns.sensor_type}?range=#{range}"
|
||||
~p"/orgs/#{socket.assigns.current_organization.slug}/equipment/#{socket.assigns.equipment_id}/graph/#{socket.assigns.sensor_type}?#{params}"
|
||||
)}
|
||||
end
|
||||
|
||||
defp build_graph_params(assigns, range) do
|
||||
base_params = %{"range" => range}
|
||||
|
||||
if assigns.interface_id do
|
||||
Map.put(base_params, "interface_id", assigns.interface_id)
|
||||
else
|
||||
base_params
|
||||
end
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp load_graph_data(socket) do
|
||||
equipment_id = socket.assigns.equipment_id
|
||||
sensor_type = socket.assigns.sensor_type
|
||||
range = socket.assigns.range
|
||||
interface_id = socket.assigns.interface_id
|
||||
|
||||
equipment = Equipment.get_equipment!(equipment_id)
|
||||
|
||||
chart_data =
|
||||
if sensor_type == "traffic" do
|
||||
load_traffic_chart_data(equipment_id, range)
|
||||
else
|
||||
sensor_types = get_sensor_types_for_chart(sensor_type)
|
||||
load_sensor_chart_data(equipment_id, sensor_types, range)
|
||||
{chart_data, title_suffix} =
|
||||
cond do
|
||||
sensor_type == "traffic" && interface_id ->
|
||||
{load_interface_traffic_chart_data(interface_id, range), get_interface_name(interface_id)}
|
||||
|
||||
sensor_type == "traffic" ->
|
||||
{load_traffic_chart_data(equipment_id, range), nil}
|
||||
|
||||
true ->
|
||||
sensor_types = get_sensor_types_for_chart(sensor_type)
|
||||
{load_sensor_chart_data(equipment_id, sensor_types, range), nil}
|
||||
end
|
||||
|
||||
{title, unit, auto_scale} = get_chart_config(sensor_type)
|
||||
chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title
|
||||
show_zero_line = sensor_type == "traffic"
|
||||
|
||||
socket
|
||||
|> assign(:equipment, equipment)
|
||||
|> assign(:page_title, "#{equipment.name} - #{title}")
|
||||
|> assign(:chart_title, title)
|
||||
|> assign(:page_title, "#{equipment.name} - #{chart_title}")
|
||||
|> assign(:chart_title, chart_title)
|
||||
|> assign(:chart_data, chart_data)
|
||||
|> assign(:unit, unit)
|
||||
|> assign(:auto_scale, auto_scale)
|
||||
|
|
@ -151,82 +172,116 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
defp build_traffic_chart_json(device, range) do
|
||||
if Enum.empty?(device.interfaces) do
|
||||
nil
|
||||
else
|
||||
since = get_datetime_from_range(range)
|
||||
limit = get_limit_for_range(range)
|
||||
all_stats = aggregate_interface_stats(device.interfaces, since, limit)
|
||||
|
||||
if Enum.empty?(all_stats) do
|
||||
nil
|
||||
else
|
||||
build_traffic_datasets(all_stats)
|
||||
end
|
||||
defp load_interface_traffic_chart_data(interface_id, range) do
|
||||
case Snmp.get_interface(interface_id) do
|
||||
nil -> nil
|
||||
interface -> build_interface_traffic_chart_json(interface, range)
|
||||
end
|
||||
end
|
||||
|
||||
defp aggregate_interface_stats(interfaces, since, limit) do
|
||||
interfaces
|
||||
|> Enum.flat_map(fn interface ->
|
||||
defp get_interface_name(interface_id) do
|
||||
case Snmp.get_interface(interface_id) do
|
||||
nil -> "Unknown Interface"
|
||||
interface -> interface.if_name || interface.if_descr || "Interface #{interface.if_index}"
|
||||
end
|
||||
end
|
||||
|
||||
defp build_traffic_chart_json(device, range) do
|
||||
if Enum.empty?(device.interfaces), do: nil, else: build_traffic_datasets(device, range)
|
||||
end
|
||||
|
||||
defp build_traffic_datasets(device, range) do
|
||||
since = get_datetime_from_range(range)
|
||||
limit = get_limit_for_range(range)
|
||||
active_interfaces = Enum.filter(device.interfaces, &(&1.if_oper_status == "up"))
|
||||
|
||||
if Enum.empty?(active_interfaces), do: nil, else: encode_interface_datasets(active_interfaces, since, limit)
|
||||
end
|
||||
|
||||
defp encode_interface_datasets(interfaces, since, limit) do
|
||||
datasets = build_per_interface_datasets(interfaces, since, limit)
|
||||
if Enum.empty?(datasets), do: nil, else: Jason.encode!(%{datasets: datasets})
|
||||
end
|
||||
|
||||
defp build_per_interface_datasets(interfaces, since, limit) do
|
||||
Enum.flat_map(interfaces, fn interface ->
|
||||
stats =
|
||||
interface.id
|
||||
|> Snmp.get_interface_stats(since: since, limit: limit)
|
||||
|> Enum.reverse()
|
||||
|
||||
Enum.map(stats, &stat_to_tuple/1)
|
||||
if Enum.empty?(stats) do
|
||||
[]
|
||||
else
|
||||
interface_name = interface.if_name || interface.if_descr
|
||||
in_data = calculate_interface_traffic_data(stats, :inbound)
|
||||
out_data = calculate_interface_traffic_data(stats, :outbound)
|
||||
|
||||
[
|
||||
%{label: "#{interface_name} Out", data: out_data},
|
||||
%{label: "#{interface_name} In", data: in_data}
|
||||
]
|
||||
end
|
||||
end)
|
||||
|> Enum.group_by(&elem(&1, 0))
|
||||
|> Enum.map(&sum_stats_by_timestamp/1)
|
||||
|> Enum.sort_by(&elem(&1, 0), DateTime)
|
||||
end
|
||||
|
||||
defp stat_to_tuple(stat) do
|
||||
{stat.checked_at, stat.if_in_octets, stat.if_out_octets}
|
||||
defp build_interface_traffic_chart_json(interface, range) do
|
||||
since = get_datetime_from_range(range)
|
||||
limit = get_limit_for_range(range)
|
||||
|
||||
stats =
|
||||
interface.id
|
||||
|> Snmp.get_interface_stats(since: since, limit: limit)
|
||||
|> Enum.reverse()
|
||||
|
||||
if Enum.empty?(stats) do
|
||||
nil
|
||||
else
|
||||
in_data = calculate_interface_traffic_data(stats, :inbound)
|
||||
out_data = calculate_interface_traffic_data(stats, :outbound)
|
||||
|
||||
datasets = [
|
||||
%{label: "Outbound", data: out_data},
|
||||
%{label: "Inbound", data: in_data}
|
||||
]
|
||||
|
||||
Jason.encode!(%{datasets: datasets})
|
||||
end
|
||||
end
|
||||
|
||||
defp sum_stats_by_timestamp({timestamp, stats}) do
|
||||
total_in = Enum.reduce(stats, 0, fn {_, in_octets, _}, acc -> acc + (in_octets || 0) end)
|
||||
total_out = Enum.reduce(stats, 0, fn {_, _, out_octets}, acc -> acc + (out_octets || 0) end)
|
||||
{timestamp, total_in, total_out}
|
||||
end
|
||||
|
||||
defp build_traffic_datasets(all_stats) do
|
||||
in_data = calculate_traffic_data(all_stats, :inbound)
|
||||
out_data = calculate_traffic_data(all_stats, :outbound)
|
||||
|
||||
datasets = [
|
||||
%{label: "Outbound", data: out_data},
|
||||
%{label: "Inbound", data: in_data}
|
||||
]
|
||||
|
||||
Jason.encode!(%{datasets: datasets})
|
||||
end
|
||||
|
||||
defp calculate_traffic_data(all_stats, :inbound) do
|
||||
all_stats
|
||||
defp calculate_interface_traffic_data(stats, :inbound) do
|
||||
stats
|
||||
|> Enum.chunk_every(2, 1, :discard)
|
||||
|> Enum.map(fn [{t1, in1, _out1}, {t2, in2, _out2}] ->
|
||||
time_diff = t2 |> DateTime.diff(t1, :second) |> max(1)
|
||||
bps = ((in2 - in1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2)
|
||||
|> Enum.map(fn [stat1, stat2] ->
|
||||
time_diff = stat2.checked_at |> DateTime.diff(stat1.checked_at, :second) |> max(1)
|
||||
|
||||
bps =
|
||||
((stat2.if_in_octets - stat1.if_in_octets) * 8 / time_diff)
|
||||
|> max(0)
|
||||
|> :erlang.float()
|
||||
|> Float.round(2)
|
||||
|
||||
%{
|
||||
x: DateTime.to_unix(t2, :millisecond),
|
||||
x: DateTime.to_unix(stat2.checked_at, :millisecond),
|
||||
y: -bps
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp calculate_traffic_data(all_stats, :outbound) do
|
||||
all_stats
|
||||
defp calculate_interface_traffic_data(stats, :outbound) do
|
||||
stats
|
||||
|> Enum.chunk_every(2, 1, :discard)
|
||||
|> Enum.map(fn [{t1, _in1, out1}, {t2, _in2, out2}] ->
|
||||
time_diff = t2 |> DateTime.diff(t1, :second) |> max(1)
|
||||
bps = ((out2 - out1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2)
|
||||
|> Enum.map(fn [stat1, stat2] ->
|
||||
time_diff = stat2.checked_at |> DateTime.diff(stat1.checked_at, :second) |> max(1)
|
||||
|
||||
bps =
|
||||
((stat2.if_out_octets - stat1.if_out_octets) * 8 / time_diff)
|
||||
|> max(0)
|
||||
|> :erlang.float()
|
||||
|> Float.round(2)
|
||||
|
||||
%{
|
||||
x: DateTime.to_unix(t2, :millisecond),
|
||||
x: DateTime.to_unix(stat2.checked_at, :millisecond),
|
||||
y: bps
|
||||
}
|
||||
end)
|
||||
|
|
|
|||
|
|
@ -1,285 +0,0 @@
|
|||
# Pangolin Security Rules for Towerops
|
||||
# Block common web attacks and malicious traffic
|
||||
#
|
||||
# Apply these rules to your Pangolin configuration to filter traffic before
|
||||
# it reaches your application server.
|
||||
#
|
||||
# Documentation: https://docs.pangolin.net/
|
||||
|
||||
# ============================================================================
|
||||
# SQL Injection Attempts
|
||||
# ============================================================================
|
||||
|
||||
[[rules]]
|
||||
name = "Block SQL Injection - UNION"
|
||||
match.url.regex = "(?i)(union.*(all|select)|select.*from.*information_schema)"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block SQL Injection - Common Patterns"
|
||||
match.url.regex = "(?i)(\\bor\\b.*=.*|\\band\\b.*=.*|'.*or.*'|\".*or.*\"|;.*drop|;.*delete|;.*insert|;.*update)"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block SQL Injection - Comments"
|
||||
match.url.regex = "(?i)(/\\*.*\\*/|--.*$|#.*$)"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
# ============================================================================
|
||||
# Cross-Site Scripting (XSS)
|
||||
# ============================================================================
|
||||
|
||||
[[rules]]
|
||||
name = "Block XSS - Script Tags"
|
||||
match.url.regex = "(?i)(<script|</script|javascript:|onerror=|onload=)"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block XSS - Event Handlers"
|
||||
match.url.regex = "(?i)(onclick|ondblclick|onmouseover|onmouseout|onkeydown|onkeyup|onchange|onsubmit|onfocus|onblur)\\s*="
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
# ============================================================================
|
||||
# Path Traversal
|
||||
# ============================================================================
|
||||
|
||||
[[rules]]
|
||||
name = "Block Path Traversal - Directory Traversal"
|
||||
match.url.regex = "(\\.\\./|\\.\\.\\\\/|%2e%2e%2f|%2e%2e/|..%2f|%2e%2e%5c)"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block Path Traversal - Absolute Paths"
|
||||
match.url.regex = "(?i)(^/etc/|^/proc/|^/sys/|^/var/|^/usr/|^/bin/|^/sbin/|c:\\\\|c:/)"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
# ============================================================================
|
||||
# Common Exploit Paths (WordPress, PHPMyAdmin, etc.)
|
||||
# ============================================================================
|
||||
|
||||
[[rules]]
|
||||
name = "Block WordPress Admin"
|
||||
match.url.path = "/wp-admin"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block WordPress Login"
|
||||
match.url.path = "/wp-login.php"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block WordPress XML-RPC"
|
||||
match.url.path = "/xmlrpc.php"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block PHPMyAdmin"
|
||||
match.url.regex = "(?i)/(phpmyadmin|pma|myadmin|mysql|phpmy|sqladmin)"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block Common Admin Panels"
|
||||
match.url.regex = "(?i)/(admin|administrator|cpanel|webmail|plesk|directadmin|phppgadmin)"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
# ============================================================================
|
||||
# Malicious Bots and Scanners
|
||||
# ============================================================================
|
||||
|
||||
[[rules]]
|
||||
name = "Block Vulnerability Scanners"
|
||||
match.headers."User-Agent".regex = "(?i)(nikto|nessus|nmap|masscan|zap|burp|sqlmap|metasploit|acunetix|w3af|havij)"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block Scrapers and Crawlers"
|
||||
match.headers."User-Agent".regex = "(?i)(scrapy|webcopier|httrack|teleport|wget|curl.*bot|python-requests.*bot)"
|
||||
action = "block"
|
||||
log = false
|
||||
|
||||
[[rules]]
|
||||
name = "Block Empty User Agent"
|
||||
match.headers."User-Agent".exact = ""
|
||||
action = "block"
|
||||
log = false
|
||||
|
||||
# ============================================================================
|
||||
# Shell Injection Attempts
|
||||
# ============================================================================
|
||||
|
||||
[[rules]]
|
||||
name = "Block Shell Commands"
|
||||
match.url.regex = "(?i)(\\|.*ls|\\|.*cat|\\|.*wget|\\|.*curl|;.*ls|;.*cat|;.*wget|;.*curl|`.*`|\\$\\(.*\\))"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block Shell Shock"
|
||||
match.headers.regex = "(?i)\\(\\)\\s*\\{.*\\}"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
# ============================================================================
|
||||
# File Upload Exploits
|
||||
# ============================================================================
|
||||
|
||||
[[rules]]
|
||||
name = "Block Malicious File Extensions"
|
||||
match.url.regex = "(?i)\\.(php\\d?|phtml|asp|aspx|jsp|cgi|pl|sh|bash|exe|dll|com|bat)\\.?"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block Double Extensions"
|
||||
match.url.regex = "(?i)\\.(jpg|jpeg|png|gif|pdf|doc|docx)\\.php"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
# ============================================================================
|
||||
# Information Disclosure
|
||||
# ============================================================================
|
||||
|
||||
[[rules]]
|
||||
name = "Block Git Directory"
|
||||
match.url.path_prefix = "/.git"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block SVN Directory"
|
||||
match.url.path_prefix = "/.svn"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block Environment Files"
|
||||
match.url.regex = "(?i)/\\.env(\\..*)?$"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block Backup Files"
|
||||
match.url.regex = "(?i)\\.(bak|backup|old|orig|save|swp|swo|~)$"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block Config Files"
|
||||
match.url.regex = "(?i)/(config|configuration|settings)\\.(php|xml|json|yml|yaml|ini|conf)$"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
# ============================================================================
|
||||
# Protocol and Method Attacks
|
||||
# ============================================================================
|
||||
|
||||
[[rules]]
|
||||
name = "Block HTTP/0.9"
|
||||
match.http_version = "0.9"
|
||||
action = "block"
|
||||
log = false
|
||||
|
||||
[[rules]]
|
||||
name = "Block TRACE Method"
|
||||
match.method = "TRACE"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block TRACK Method"
|
||||
match.method = "TRACK"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
# ============================================================================
|
||||
# Known Exploit Patterns
|
||||
# ============================================================================
|
||||
|
||||
[[rules]]
|
||||
name = "Block Log4Shell"
|
||||
match.headers.regex = "(?i)\\$\\{jndi:(ldap|rmi|dns)://"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block Server-Side Request Forgery (SSRF)"
|
||||
match.url.regex = "(?i)(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|\\[::1\\]|169\\.254\\.|192\\.168\\.|10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.)"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
[[rules]]
|
||||
name = "Block XXE (XML External Entity)"
|
||||
match.body.regex = "(?i)<!entity.*system"
|
||||
action = "block"
|
||||
log = true
|
||||
|
||||
# ============================================================================
|
||||
# Rate Limiting (requires Pangolin Pro)
|
||||
# ============================================================================
|
||||
|
||||
# Uncomment if you have Pangolin Pro with rate limiting
|
||||
# [[rules]]
|
||||
# name = "Rate Limit - General"
|
||||
# match.all = true
|
||||
# action = "rate_limit"
|
||||
# rate_limit.requests = 100
|
||||
# rate_limit.window = "1m"
|
||||
# rate_limit.by = "ip"
|
||||
|
||||
# [[rules]]
|
||||
# name = "Rate Limit - Login Endpoints"
|
||||
# match.url.regex = "(?i)/(login|signin|session|auth)"
|
||||
# action = "rate_limit"
|
||||
# rate_limit.requests = 10
|
||||
# rate_limit.window = "1m"
|
||||
# rate_limit.by = "ip"
|
||||
|
||||
# ============================================================================
|
||||
# Geo-Blocking (optional)
|
||||
# ============================================================================
|
||||
|
||||
# Block traffic from specific countries if needed
|
||||
# [[rules]]
|
||||
# name = "Block Countries"
|
||||
# match.geo.country_code = ["CN", "RU", "KP"]
|
||||
# action = "block"
|
||||
# log = false
|
||||
|
||||
# ============================================================================
|
||||
# Allowlist for Legitimate Traffic
|
||||
# ============================================================================
|
||||
|
||||
# Always allow health checks
|
||||
[[rules]]
|
||||
name = "Allow Health Checks"
|
||||
match.url.path = "/health"
|
||||
action = "allow"
|
||||
priority = 1000
|
||||
|
||||
# Allow legitimate API endpoints
|
||||
[[rules]]
|
||||
name = "Allow API v1"
|
||||
match.url.path_prefix = "/api/v1"
|
||||
match.headers."User-Agent".regex = "towerops-agent"
|
||||
action = "allow"
|
||||
priority = 1000
|
||||
|
||||
# Allow legitimate user agents from monitoring
|
||||
[[rules]]
|
||||
name = "Allow Monitoring Services"
|
||||
match.headers."User-Agent".regex = "(?i)(uptimerobot|pingdom|statuspage|datadog)"
|
||||
action = "allow"
|
||||
priority = 1000
|
||||
|
|
@ -50,6 +50,7 @@ message Metric {
|
|||
oneof metric_type {
|
||||
SensorReading sensor_reading = 1;
|
||||
InterfaceStat interface_stat = 2;
|
||||
NeighborDiscovery neighbor_discovery = 3;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +72,20 @@ message InterfaceStat {
|
|||
int64 timestamp = 8; // Unix timestamp in seconds
|
||||
}
|
||||
|
||||
message NeighborDiscovery {
|
||||
string interface_id = 1;
|
||||
string protocol = 2; // "lldp" or "cdp"
|
||||
string remote_chassis_id = 3;
|
||||
string remote_system_name = 4;
|
||||
string remote_system_description = 5;
|
||||
string remote_platform = 6;
|
||||
string remote_port_id = 7;
|
||||
string remote_port_description = 8;
|
||||
string remote_address = 9;
|
||||
repeated string remote_capabilities = 10;
|
||||
int64 timestamp = 11; // Unix timestamp in seconds
|
||||
}
|
||||
|
||||
// Heartbeat metadata
|
||||
message HeartbeatMetadata {
|
||||
string version = 1;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateSnmpNeighbors do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:snmp_neighbors) do
|
||||
add :equipment_id, references(:equipment, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :interface_id, references(:snmp_interfaces, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
# Discovery protocol (lldp, cdp)
|
||||
add :protocol, :string, null: false
|
||||
|
||||
# Remote device identification
|
||||
add :remote_chassis_id, :string
|
||||
add :remote_system_name, :string
|
||||
add :remote_system_description, :text
|
||||
add :remote_platform, :string
|
||||
|
||||
# Remote interface/port information
|
||||
add :remote_port_id, :string
|
||||
add :remote_port_description, :string
|
||||
|
||||
# Remote device address (IP)
|
||||
add :remote_address, :string
|
||||
|
||||
# Capabilities as a list of strings (router, switch, bridge, etc.)
|
||||
add :remote_capabilities, {:array, :string}, default: []
|
||||
|
||||
# Last time this neighbor was seen
|
||||
add :last_discovered_at, :utc_datetime, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
# Indexes for performance
|
||||
create index(:snmp_neighbors, [:equipment_id])
|
||||
create index(:snmp_neighbors, [:interface_id])
|
||||
create index(:snmp_neighbors, [:protocol])
|
||||
create index(:snmp_neighbors, [:last_discovered_at])
|
||||
|
||||
# Unique constraint: one neighbor per interface + remote chassis + protocol
|
||||
create unique_index(:snmp_neighbors, [:interface_id, :remote_chassis_id, :protocol])
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue