diff --git a/assets/js/app.ts b/assets/js/app.ts index 9d599eb6..ebdeeecf 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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, diff --git a/lib/towerops/proto/agent.pb.ex b/lib/towerops/proto/agent.pb.ex index 195e666f..c460b05c 100644 --- a/lib/towerops/proto/agent.pb.ex +++ b/lib/towerops/proto/agent.pb.ex @@ -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 diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index d060fdb9..c4da6d1b 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -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 diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 6be2ad67..85a53d60 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -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 diff --git a/lib/towerops/snmp/neighbor.ex b/lib/towerops/snmp/neighbor.ex new file mode 100644 index 00000000..c24a0ba2 --- /dev/null +++ b/lib/towerops/snmp/neighbor.ex @@ -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 diff --git a/lib/towerops/snmp/neighbor_discovery.ex b/lib/towerops/snmp/neighbor_discovery.ex new file mode 100644 index 00000000..f61430f8 --- /dev/null +++ b/lib/towerops/snmp/neighbor_discovery.ex @@ -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 + <> = 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 + <> = 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 diff --git a/lib/towerops_web/controllers/api/agent_controller.ex b/lib/towerops_web/controllers/api/agent_controller.ex index be96291b..e591b5bb 100644 --- a/lib/towerops_web/controllers/api/agent_controller.ex +++ b/lib/towerops_web/controllers/api/agent_controller.ex @@ -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 diff --git a/lib/towerops_web/controllers/api/mobile_controller.ex b/lib/towerops_web/controllers/api/mobile_controller.ex index 39f3e290..0b24d1a4 100644 --- a/lib/towerops_web/controllers/api/mobile_controller.ex +++ b/lib/towerops_web/controllers/api/mobile_controller.ex @@ -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) diff --git a/lib/towerops_web/live/equipment_live/show.ex b/lib/towerops_web/live/equipment_live/show.ex index 3c5f00b4..a6389503 100644 --- a/lib/towerops_web/live/equipment_live/show.ex +++ b/lib/towerops_web/live/equipment_live/show.ex @@ -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) diff --git a/lib/towerops_web/live/equipment_live/show.html.heex b/lib/towerops_web/live/equipment_live/show.html.heex index 8dc2d3be..648b1c8d 100644 --- a/lib/towerops_web/live/equipment_live/show.html.heex +++ b/lib/towerops_web/live/equipment_live/show.html.heex @@ -51,6 +51,20 @@ <% 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 patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=logs"} class={[ @@ -82,30 +96,30 @@
-
+
<%= if @snmp_device do %> -
+
System Name
{@snmp_device.sys_name || @equipment.name}
-
+
Resolved IP
{@equipment.ip_address}
-
+
Hardware
{@snmp_device.model || "N/A"}
-
+
Operating System
{if @snmp_device.manufacturer && @snmp_device.firmware_version do @@ -116,42 +130,42 @@
-
+
Object ID
{@snmp_device.sys_object_id || "N/A"}
-
+
Contact
{@snmp_device.sys_contact || "N/A"}
-
+
Location
{@snmp_device.sys_location || "N/A"}
-
+
Uptime
{format_uptime(@snmp_device.sys_uptime)}
<% else %> -
+
Name
{@equipment.name}
-
+
IP Address
{@equipment.ip_address} @@ -159,14 +173,14 @@
<% end %> -
+
Device Added
{format_device_age(@equipment.inserted_at)}
-
+
Last Discovered
{if @equipment.last_discovery_at do @@ -278,9 +292,9 @@
-
+
<%= for sensor <- @storage_sensors do %> -
+
<.link navigate={ @@ -317,9 +331,9 @@
-
+
<%= for sensor <- @temperature_sensors do %> -
+
<.link navigate={ @@ -354,9 +368,9 @@

Voltage

-
+
<%= for sensor <- @voltage_sensors do %> -
+
<.link navigate={ @@ -413,14 +427,21 @@ {interface.if_index} -
- {interface.if_name || interface.if_descr} -
- <%= if interface.if_alias do %> -
- {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" + > +
+ {interface.if_name || interface.if_descr}
- <% end %> + <%= if interface.if_alias do %> +
+ {interface.if_alias} +
+ <% end %> + No interfaces discovered

<% end %> + <% "neighbors" -> %> + <%= if @neighbors && length(@neighbors) > 0 do %> +
+
+

+ Discovered Neighbors +

+
+
+ + + + + + + + + + + + + + <%= for neighbor <- @neighbors do %> + + + + + + + + + + <% end %> + +
ProtocolLocal InterfaceRemote DeviceRemote PortPlatformCapabilitiesLast Seen
+ + {neighbor.protocol} + + + {neighbor.interface.if_name || neighbor.interface.if_descr} + +
+ {neighbor.remote_system_name || "Unknown"} +
+ <%= if neighbor.remote_address do %> +
+ {neighbor.remote_address} +
+ <% end %> +
+
+ {neighbor.remote_port_id || "-"} +
+ <%= if neighbor.remote_port_description do %> +
+ {neighbor.remote_port_description} +
+ <% end %> +
+ {neighbor.remote_platform || "-"} + + <%= if neighbor.remote_capabilities && length(neighbor.remote_capabilities) > 0 do %> + {Enum.join(neighbor.remote_capabilities, ", ")} + <% else %> + - + <% end %> + + {time_ago(neighbor.last_discovered_at)} +
+
+
+ <% else %> +
+
+ <.icon name="hero-server-stack" class="mx-auto h-12 w-12 text-zinc-400" /> +

+ No neighbors discovered +

+

+ This device doesn't have any LLDP or CDP neighbors, or neighbor discovery hasn't run yet. +

+
+
+ <% end %> <% "logs" -> %>
diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex index 502a7761..bc536039 100644 --- a/lib/towerops_web/live/graph_live/show.ex +++ b/lib/towerops_web/live/graph_live/show.ex @@ -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) diff --git a/pangolin-security-rules.toml b/pangolin-security-rules.toml deleted file mode 100644 index bcc1cecd..00000000 --- a/pangolin-security-rules.toml +++ /dev/null @@ -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)(