diff --git a/lib/towerops/capacity.ex b/lib/towerops/capacity.ex new file mode 100644 index 00000000..b57a8510 --- /dev/null +++ b/lib/towerops/capacity.ex @@ -0,0 +1,177 @@ +defmodule Towerops.Capacity do + @moduledoc """ + Context for backhaul capacity analysis. + + Provides throughput calculation from interface stats, utilization percentages, + and site/organization-level capacity summaries. + """ + + import Ecto.Query + + alias Towerops.Devices.Device + alias Towerops.Repo + alias Towerops.Snmp.Interface + alias Towerops.Snmp.InterfaceStat + + @doc """ + Calculates average throughput for an interface over a time window. + Returns `%{in_bps: float, out_bps: float, max_bps: float}`. + """ + def calculate_throughput(interface_id, since, until \\ DateTime.utc_now()) do + stats = + InterfaceStat + |> where([s], s.interface_id == ^interface_id) + |> where([s], s.checked_at >= ^since and s.checked_at <= ^until) + |> order_by([s], asc: s.checked_at) + |> Repo.all() + + calculate_throughput_from_stats(stats) + end + + @doc """ + Calculates utilization for an interface with configured capacity. + Returns `%{utilization_pct: float, in_pct: float, out_pct: float, throughput: map}` or nil. + """ + def get_utilization(%Interface{configured_capacity_bps: nil}), do: nil + def get_utilization(%Interface{configured_capacity_bps: cap}) when cap <= 0, do: nil + + def get_utilization(%Interface{id: id, configured_capacity_bps: cap}) do + since = DateTime.add(DateTime.utc_now(), -900, :second) + throughput = calculate_throughput(id, since) + + %{ + utilization_pct: throughput.max_bps / cap * 100, + in_pct: throughput.in_bps / cap * 100, + out_pct: throughput.out_bps / cap * 100, + throughput: throughput + } + end + + @doc """ + Returns a capacity summary for all interfaces at a site. + """ + def get_site_capacity_summary(site_id) do + interfaces = list_capacity_interfaces_for_site(site_id) + + interface_summaries = + Enum.map(interfaces, fn {iface, device_name} -> + utilization = get_utilization(iface) + + %{ + id: iface.id, + name: iface.if_name, + device_name: device_name, + capacity_bps: iface.configured_capacity_bps, + capacity_source: iface.capacity_source, + throughput: if(utilization, do: utilization.throughput, else: zero_throughput()), + utilization_pct: if(utilization, do: utilization.utilization_pct, else: 0.0) + } + end) + + total_capacity = Enum.sum(Enum.map(interface_summaries, & &1.capacity_bps)) + total_throughput = Enum.sum(Enum.map(interface_summaries, & &1.throughput.max_bps)) + + utilization_pct = + if total_capacity > 0, do: total_throughput / total_capacity * 100, else: 0.0 + + %{ + total_capacity_bps: total_capacity, + total_throughput_bps: total_throughput, + utilization_pct: utilization_pct, + interfaces: interface_summaries, + status: utilization_status(utilization_pct) + } + end + + @doc """ + Returns per-site capacity summaries for an organization. + Only includes sites that have at least one interface with capacity configured. + """ + def get_organization_capacity_summary(organization_id) do + site_ids = + Interface + |> join(:inner, [i], sd in Towerops.Snmp.Device, on: i.snmp_device_id == sd.id) + |> join(:inner, [_i, sd], d in Device, on: sd.device_id == d.id) + |> where([_i, _sd, d], d.organization_id == ^organization_id) + |> where([i], not is_nil(i.configured_capacity_bps)) + |> select([_i, _sd, d], d.site_id) + |> distinct(true) + |> Repo.all() + |> Enum.reject(&is_nil/1) + + sites = + Towerops.Sites.Site + |> where([s], s.id in ^site_ids) + |> Repo.all() + + Enum.map(sites, fn site -> + summary = get_site_capacity_summary(site.id) + + %{ + site_id: site.id, + site_name: site.name, + total_capacity_bps: summary.total_capacity_bps, + total_throughput_bps: summary.total_throughput_bps, + utilization_pct: summary.utilization_pct, + status: summary.status, + headroom_bps: max(0, summary.total_capacity_bps - summary.total_throughput_bps) + } + end) + end + + defp list_capacity_interfaces_for_site(site_id) do + Interface + |> join(:inner, [i], sd in Towerops.Snmp.Device, on: i.snmp_device_id == sd.id) + |> join(:inner, [_i, sd], d in Device, on: sd.device_id == d.id) + |> where([_i, _sd, d], d.site_id == ^site_id) + |> where([i], not is_nil(i.configured_capacity_bps)) + |> select([i, _sd, d], {i, d.name}) + |> Repo.all() + end + + defp calculate_throughput_from_stats(stats) when length(stats) < 2 do + zero_throughput() + end + + defp calculate_throughput_from_stats(stats) do + pairs = + stats + |> Enum.chunk_every(2, 1, :discard) + |> Enum.map(fn [s1, s2] -> + time_diff = s2.checked_at |> DateTime.diff(s1.checked_at, :second) |> max(1) + + in_bps = calculate_bps(s1.if_in_octets, s2.if_in_octets, time_diff) + out_bps = calculate_bps(s1.if_out_octets, s2.if_out_octets, time_diff) + + {in_bps, out_bps} + end) + + if Enum.empty?(pairs) do + zero_throughput() + else + avg_in = Enum.sum(Enum.map(pairs, &elem(&1, 0))) / length(pairs) + avg_out = Enum.sum(Enum.map(pairs, &elem(&1, 1))) / length(pairs) + + %{ + in_bps: Float.round(avg_in, 2), + out_bps: Float.round(avg_out, 2), + max_bps: Float.round(max(avg_in, avg_out), 2) + } + end + end + + defp calculate_bps(nil, _, _), do: 0.0 + defp calculate_bps(_, nil, _), do: 0.0 + + defp calculate_bps(prev_octets, curr_octets, time_diff) do + delta = curr_octets - prev_octets + # Clamp negative deltas to 0 (counter wrap) + max(0.0, delta * 8 / time_diff) + end + + defp zero_throughput, do: %{in_bps: 0.0, out_bps: 0.0, max_bps: 0.0} + + defp utilization_status(pct) when pct >= 90, do: :red + defp utilization_status(pct) when pct >= 70, do: :yellow + defp utilization_status(_), do: :green +end diff --git a/lib/towerops/capacity/resolver.ex b/lib/towerops/capacity/resolver.ex new file mode 100644 index 00000000..de900405 --- /dev/null +++ b/lib/towerops/capacity/resolver.ex @@ -0,0 +1,98 @@ +defmodule Towerops.Capacity.Resolver do + @moduledoc """ + Resolves the effective capacity for an interface using a priority cascade: + + 1. Manual override (capacity_source = "manual") + 2. Sensor-based (capacity/rate sensors from vendor profiles) + 3. if_speed (SNMP link speed) + """ + + import Ecto.Query + + alias Towerops.Repo + alias Towerops.Snmp.Sensor + + @capacity_sensor_types ~w(capacity rate) + + @doc """ + Resolves the capacity for an interface, returning `{capacity_bps, source}` or `nil`. + """ + @spec resolve_capacity(map(), map()) :: {pos_integer(), String.t()} | nil + def resolve_capacity(interface, snmp_device) do + resolve_manual(interface) || + resolve_sensor(snmp_device) || + resolve_if_speed(interface) + end + + @doc """ + Finds the highest capacity/rate sensor value for a device, normalized to bps. + Returns the value in bps or nil. + """ + @spec resolve_from_sensors(map()) :: pos_integer() | nil + def resolve_from_sensors(snmp_device) do + sensors = + Sensor + |> where([s], s.snmp_device_id == ^snmp_device.id) + |> where([s], s.sensor_type in ^@capacity_sensor_types) + |> where([s], not is_nil(s.last_value) and s.last_value > 0.0) + |> Repo.all() + + sensors + |> Enum.map(&normalize_sensor_to_bps/1) + |> Enum.reject(&is_nil/1) + |> Enum.max(fn -> nil end) + end + + @doc """ + Normalizes a sensor's last_value to bits per second based on its unit. + """ + @spec normalize_sensor_to_bps(Sensor.t()) :: pos_integer() | nil + def normalize_sensor_to_bps(%Sensor{last_value: nil}), do: nil + def normalize_sensor_to_bps(%Sensor{last_value: v}) when v <= 0, do: nil + + def normalize_sensor_to_bps(%Sensor{last_value: value, sensor_unit: unit, sensor_divisor: divisor}) do + adjusted = value / (divisor || 1) + + bps = + case normalize_unit(unit) do + :bps -> adjusted + :kbps -> adjusted * 1_000 + :mbps -> adjusted * 1_000_000 + :gbps -> adjusted * 1_000_000_000 + _ -> adjusted + end + + trunc(bps) + end + + defp normalize_unit(nil), do: :bps + + defp normalize_unit(unit) when is_binary(unit) do + case String.downcase(unit) do + "bps" -> :bps + "kbps" -> :kbps + "mbps" -> :mbps + "gbps" -> :gbps + _ -> :bps + end + end + + defp resolve_manual(%{capacity_source: "manual", configured_capacity_bps: bps}) when is_integer(bps) and bps > 0 do + {bps, "manual"} + end + + defp resolve_manual(_), do: nil + + defp resolve_sensor(snmp_device) do + case resolve_from_sensors(snmp_device) do + nil -> nil + bps -> {bps, "sensor"} + end + end + + defp resolve_if_speed(%{if_speed: speed}) when is_integer(speed) and speed > 0 do + {speed, "if_speed"} + end + + defp resolve_if_speed(_), do: nil +end diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index fbdc8e87..24309eda 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -555,6 +555,36 @@ defmodule Towerops.Snmp do end end + @doc """ + Sets a manual capacity override on an interface. + """ + def set_manual_capacity(interface_id, capacity_bps) do + case Repo.get(Interface, interface_id) do + nil -> + {:error, :not_found} + + interface -> + interface + |> Interface.changeset(%{configured_capacity_bps: capacity_bps, capacity_source: "manual"}) + |> Repo.update() + end + end + + @doc """ + Clears the capacity override on an interface, resetting both fields to nil. + """ + def clear_manual_capacity(interface_id) do + case Repo.get(Interface, interface_id) do + nil -> + {:error, :not_found} + + interface -> + interface + |> Ecto.Changeset.change(%{configured_capacity_bps: nil, capacity_source: nil}) + |> Repo.update() + end + end + @doc """ Records a new sensor reading. """ diff --git a/lib/towerops/snmp/interface.ex b/lib/towerops/snmp/interface.ex index 2a146352..3e65e536 100644 --- a/lib/towerops/snmp/interface.ex +++ b/lib/towerops/snmp/interface.ex @@ -28,6 +28,8 @@ defmodule Towerops.Snmp.Interface do field :if_admin_status, :string field :if_oper_status, :string field :monitored, :boolean, default: true + field :configured_capacity_bps, :integer + field :capacity_source, :string belongs_to :snmp_device, Device @@ -50,6 +52,8 @@ defmodule Towerops.Snmp.Interface do if_admin_status: String.t() | nil, if_oper_status: String.t() | nil, monitored: boolean(), + configured_capacity_bps: integer() | nil, + capacity_source: String.t() | nil, snmp_device_id: Ecto.UUID.t(), snmp_device: NotLoaded.t() | Device.t(), stats: NotLoaded.t() | [InterfaceStat.t()], @@ -71,9 +75,13 @@ defmodule Towerops.Snmp.Interface do :if_phys_address, :if_admin_status, :if_oper_status, - :monitored + :monitored, + :configured_capacity_bps, + :capacity_source ]) |> validate_required([:snmp_device_id, :if_index]) + |> validate_inclusion(:capacity_source, ~w(manual sensor if_speed peak)) + |> validate_number(:configured_capacity_bps, greater_than: 0) |> unique_constraint([:snmp_device_id, :if_index]) |> foreign_key_constraint(:snmp_device_id) end diff --git a/priv/repo/migrations/20260306234425_add_capacity_fields_to_interfaces.exs b/priv/repo/migrations/20260306234425_add_capacity_fields_to_interfaces.exs new file mode 100644 index 00000000..2c3d1150 --- /dev/null +++ b/priv/repo/migrations/20260306234425_add_capacity_fields_to_interfaces.exs @@ -0,0 +1,10 @@ +defmodule Towerops.Repo.Migrations.AddCapacityFieldsToInterfaces do + use Ecto.Migration + + def change do + alter table(:snmp_interfaces) do + add :configured_capacity_bps, :bigint + add :capacity_source, :string + end + end +end diff --git a/test/towerops/capacity/resolver_test.exs b/test/towerops/capacity/resolver_test.exs new file mode 100644 index 00000000..3bf0006b --- /dev/null +++ b/test/towerops/capacity/resolver_test.exs @@ -0,0 +1,255 @@ +defmodule Towerops.Capacity.ResolverTest do + use Towerops.DataCase + + import Towerops.AccountsFixtures + + alias Towerops.Capacity.Resolver + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.Interface + alias Towerops.Snmp.Sensor + + setup do + user = user_fixture() + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "Test Radio", + ip_address: "10.0.0.1", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + site_id: site.id, + organization_id: organization.id + }) + + snmp_device = + %SnmpDevice{} + |> SnmpDevice.changeset(%{ + device_id: device.id, + sys_name: "test-radio", + sys_descr: "Cambium PTP 650" + }) + |> Repo.insert!() + + interface = + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: snmp_device.id, + if_index: 1, + if_name: "eth0", + if_speed: 1_000_000_000, + if_type: 6 + }) + |> Repo.insert!() + + %{ + device: device, + snmp_device: snmp_device, + interface: interface, + organization: organization, + site: site + } + end + + describe "resolve_capacity/2" do + test "returns manual override when set", %{interface: interface, snmp_device: snmp_device} do + interface = + interface + |> Interface.changeset(%{ + configured_capacity_bps: 300_000_000, + capacity_source: "manual" + }) + |> Repo.update!() + + assert {300_000_000, "manual"} = Resolver.resolve_capacity(interface, snmp_device) + end + + test "returns sensor-based capacity when no manual override", %{ + interface: interface, + snmp_device: snmp_device + } do + # Create a capacity sensor (Cambium PTP style, reports in kbps) + %Sensor{} + |> Sensor.changeset(%{ + snmp_device_id: snmp_device.id, + sensor_type: "capacity", + sensor_index: "cap_1", + sensor_oid: "1.3.6.1.4.1.17713.21.1.2.8", + sensor_descr: "Link Capacity", + sensor_unit: "kbps", + sensor_divisor: 1, + last_value: 300_000.0 + }) + |> Repo.insert!() + + assert {300_000_000, "sensor"} = Resolver.resolve_capacity(interface, snmp_device) + end + + test "returns if_speed when no manual override or sensors", %{ + interface: interface, + snmp_device: snmp_device + } do + assert {1_000_000_000, "if_speed"} = Resolver.resolve_capacity(interface, snmp_device) + end + + test "returns nil when no capacity data available", %{snmp_device: snmp_device} do + interface = + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: snmp_device.id, + if_index: 99, + if_name: "lo0", + if_speed: nil, + if_type: 24 + }) + |> Repo.insert!() + + assert nil == Resolver.resolve_capacity(interface, snmp_device) + end + + test "manual override takes priority over sensor data", %{ + interface: interface, + snmp_device: snmp_device + } do + # Set manual capacity + interface = + interface + |> Interface.changeset(%{ + configured_capacity_bps: 150_000_000, + capacity_source: "manual" + }) + |> Repo.update!() + + # Also create a sensor with different value + %Sensor{} + |> Sensor.changeset(%{ + snmp_device_id: snmp_device.id, + sensor_type: "capacity", + sensor_index: "cap_1", + sensor_oid: "1.3.6.1.4.1.17713.21.1.2.8", + sensor_descr: "Link Capacity", + sensor_unit: "kbps", + sensor_divisor: 1, + last_value: 300_000.0 + }) + |> Repo.insert!() + + # Manual should win + assert {150_000_000, "manual"} = Resolver.resolve_capacity(interface, snmp_device) + end + + test "sensor takes priority over if_speed", %{interface: interface, snmp_device: snmp_device} do + # Interface has if_speed of 1 Gbps, sensor reports 300 Mbps + %Sensor{} + |> Sensor.changeset(%{ + snmp_device_id: snmp_device.id, + sensor_type: "rate", + sensor_index: "rate_tx_1", + sensor_oid: "1.3.6.1.4.1.41112.1.4.5.1.9", + sensor_descr: "TX Rate", + sensor_unit: "bps", + sensor_divisor: 1, + last_value: 300_000_000.0 + }) + |> Repo.insert!() + + assert {300_000_000, "sensor"} = Resolver.resolve_capacity(interface, snmp_device) + end + end + + describe "normalize_sensor_to_bps/1" do + test "handles kbps unit" do + sensor = %Sensor{last_value: 300_000.0, sensor_unit: "kbps", sensor_divisor: 1} + assert 300_000_000 == Resolver.normalize_sensor_to_bps(sensor) + end + + test "handles Mbps unit" do + sensor = %Sensor{last_value: 450.0, sensor_unit: "Mbps", sensor_divisor: 1} + assert 450_000_000 == Resolver.normalize_sensor_to_bps(sensor) + end + + test "handles bps unit" do + sensor = %Sensor{last_value: 100_000_000.0, sensor_unit: "bps", sensor_divisor: 1} + assert 100_000_000 == Resolver.normalize_sensor_to_bps(sensor) + end + + test "handles Gbps unit" do + sensor = %Sensor{last_value: 1.5, sensor_unit: "Gbps", sensor_divisor: 1} + assert 1_500_000_000 == Resolver.normalize_sensor_to_bps(sensor) + end + + test "applies sensor_divisor" do + sensor = %Sensor{last_value: 3000.0, sensor_unit: "kbps", sensor_divisor: 10} + assert 300_000 == Resolver.normalize_sensor_to_bps(sensor) + end + + test "returns nil for nil last_value" do + sensor = %Sensor{last_value: nil, sensor_unit: "kbps", sensor_divisor: 1} + assert nil == Resolver.normalize_sensor_to_bps(sensor) + end + + test "returns nil for zero last_value" do + sensor = %Sensor{last_value: 0.0, sensor_unit: "kbps", sensor_divisor: 1} + assert nil == Resolver.normalize_sensor_to_bps(sensor) + end + end + + describe "resolve_from_sensors/1" do + test "returns highest capacity sensor value", %{snmp_device: snmp_device} do + # TX capacity sensor + %Sensor{} + |> Sensor.changeset(%{ + snmp_device_id: snmp_device.id, + sensor_type: "capacity", + sensor_index: "cap_tx_1", + sensor_oid: "1.3.6.1.4.1.17713.21.1.2.8", + sensor_descr: "TX Capacity", + sensor_unit: "kbps", + sensor_divisor: 1, + last_value: 200_000.0 + }) + |> Repo.insert!() + + # RX capacity sensor (higher) + %Sensor{} + |> Sensor.changeset(%{ + snmp_device_id: snmp_device.id, + sensor_type: "capacity", + sensor_index: "cap_rx_1", + sensor_oid: "1.3.6.1.4.1.17713.21.1.2.9", + sensor_descr: "RX Capacity", + sensor_unit: "kbps", + sensor_divisor: 1, + last_value: 300_000.0 + }) + |> Repo.insert!() + + assert 300_000_000 == Resolver.resolve_from_sensors(snmp_device) + end + + test "returns nil when no capacity/rate sensors exist", %{snmp_device: snmp_device} do + # Create a non-capacity sensor + %Sensor{} + |> Sensor.changeset(%{ + snmp_device_id: snmp_device.id, + sensor_type: "temperature", + sensor_index: "temp_1", + sensor_oid: "1.3.6.1.4.1.17713.21.1.1.1", + sensor_descr: "Board Temperature", + sensor_unit: "C", + sensor_divisor: 1, + last_value: 45.0 + }) + |> Repo.insert!() + + assert nil == Resolver.resolve_from_sensors(snmp_device) + end + end +end diff --git a/test/towerops/capacity_test.exs b/test/towerops/capacity_test.exs new file mode 100644 index 00000000..1ee992af --- /dev/null +++ b/test/towerops/capacity_test.exs @@ -0,0 +1,263 @@ +defmodule Towerops.CapacityTest do + use Towerops.DataCase + + import Towerops.AccountsFixtures + + alias Towerops.Capacity + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.Interface + alias Towerops.Snmp.InterfaceStat + + setup do + user = user_fixture() + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test ISP"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Tower Alpha", + organization_id: organization.id + }) + + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "PTP Radio", + ip_address: "10.0.0.1", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + site_id: site.id, + organization_id: organization.id + }) + + snmp_device = + %SnmpDevice{} + |> SnmpDevice.changeset(%{device_id: device.id, sys_name: "radio", sys_descr: "Cambium PTP"}) + |> Repo.insert!() + + interface = + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: snmp_device.id, + if_index: 1, + if_name: "eth0", + if_speed: 1_000_000_000, + if_type: 6, + configured_capacity_bps: 300_000_000, + capacity_source: "manual" + }) + |> Repo.insert!() + + %{ + organization: organization, + site: site, + device: device, + snmp_device: snmp_device, + interface: interface + } + end + + describe "calculate_throughput/3" do + test "calculates throughput from interface stats", %{interface: interface} do + now = DateTime.utc_now() + t1 = DateTime.add(now, -120, :second) + t2 = DateTime.add(now, -60, :second) + + insert_interface_stat(interface.id, %{ + if_in_octets: 1_000_000, + if_out_octets: 2_000_000, + checked_at: t1 + }) + + insert_interface_stat(interface.id, %{ + if_in_octets: 1_500_000, + if_out_octets: 3_000_000, + checked_at: t2 + }) + + result = Capacity.calculate_throughput(interface.id, DateTime.add(now, -180, :second)) + + # (500_000 bytes * 8) / 60 seconds = 66_666.67 bps + assert_in_delta result.in_bps, 66_666.67, 1.0 + # (1_000_000 bytes * 8) / 60 seconds = 133_333.33 bps + assert_in_delta result.out_bps, 133_333.33, 1.0 + assert_in_delta result.max_bps, 133_333.33, 1.0 + end + + test "clamps negative deltas to zero (counter wrap)", %{interface: interface} do + now = DateTime.utc_now() + t1 = DateTime.add(now, -120, :second) + t2 = DateTime.add(now, -60, :second) + + insert_interface_stat(interface.id, %{ + if_in_octets: 5_000_000, + if_out_octets: 5_000_000, + checked_at: t1 + }) + + # Counter wrapped - lower value than previous + insert_interface_stat(interface.id, %{ + if_in_octets: 1_000_000, + if_out_octets: 6_000_000, + checked_at: t2 + }) + + result = Capacity.calculate_throughput(interface.id, DateTime.add(now, -180, :second)) + + # Negative delta clamped to 0 + assert result.in_bps == 0.0 + assert_in_delta result.out_bps, 133_333.33, 1.0 + end + + test "returns zero throughput when no stats exist", %{interface: interface} do + result = Capacity.calculate_throughput(interface.id, DateTime.add(DateTime.utc_now(), -3600, :second)) + assert result.in_bps == 0.0 + assert result.out_bps == 0.0 + assert result.max_bps == 0.0 + end + + test "returns zero throughput with only one stat point", %{interface: interface} do + insert_interface_stat(interface.id, %{ + if_in_octets: 1_000_000, + if_out_octets: 2_000_000, + checked_at: DateTime.utc_now() + }) + + result = Capacity.calculate_throughput(interface.id, DateTime.add(DateTime.utc_now(), -3600, :second)) + assert result.in_bps == 0.0 + assert result.out_bps == 0.0 + end + end + + describe "get_utilization/1" do + test "calculates utilization percentage", %{interface: interface} do + now = DateTime.utc_now() + + insert_interface_stat(interface.id, %{ + if_in_octets: 0, + if_out_octets: 0, + checked_at: DateTime.add(now, -120, :second) + }) + + # 300 Mbps capacity, sending at ~150 Mbps = 50% utilization + # 150 Mbps = 18_750_000 bytes/sec * 60 sec = 1_125_000_000 bytes + insert_interface_stat(interface.id, %{ + if_in_octets: 0, + if_out_octets: 1_125_000_000, + checked_at: DateTime.add(now, -60, :second) + }) + + result = Capacity.get_utilization(interface) + assert result + assert_in_delta result.utilization_pct, 50.0, 1.0 + end + + test "returns nil when interface has no configured capacity", %{snmp_device: snmp_device} do + interface = + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: snmp_device.id, + if_index: 99, + if_name: "lo0" + }) + |> Repo.insert!() + + assert nil == Capacity.get_utilization(interface) + end + end + + describe "get_site_capacity_summary/1" do + test "aggregates capacity across interfaces at a site", %{ + site: site, + snmp_device: snmp_device, + interface: interface + } do + now = DateTime.utc_now() + + # Add stats for existing interface (300 Mbps capacity) + insert_interface_stat(interface.id, %{ + if_in_octets: 0, + if_out_octets: 0, + checked_at: DateTime.add(now, -120, :second) + }) + + insert_interface_stat(interface.id, %{ + if_in_octets: 0, + if_out_octets: 1_125_000_000, + checked_at: DateTime.add(now, -60, :second) + }) + + # Add a second device with capacity at the same site + {:ok, device2} = + Towerops.Devices.create_device(%{ + name: "Second Radio", + ip_address: "10.0.0.2", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + site_id: site.id, + organization_id: site.organization_id + }) + + snmp_device2 = + %SnmpDevice{} + |> SnmpDevice.changeset(%{device_id: device2.id, sys_name: "radio2", sys_descr: "Test"}) + |> Repo.insert!() + + interface2 = + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: snmp_device2.id, + if_index: 1, + if_name: "eth0", + configured_capacity_bps: 500_000_000, + capacity_source: "sensor" + }) + |> Repo.insert!() + + insert_interface_stat(interface2.id, %{ + if_in_octets: 0, + if_out_octets: 0, + checked_at: DateTime.add(now, -120, :second) + }) + + insert_interface_stat(interface2.id, %{ + if_in_octets: 0, + if_out_octets: 0, + checked_at: DateTime.add(now, -60, :second) + }) + + summary = Capacity.get_site_capacity_summary(site.id) + + # 300M + 500M = 800M total capacity + assert summary.total_capacity_bps == 800_000_000 + assert length(summary.interfaces) == 2 + assert summary.utilization_pct >= 0 + end + + test "returns empty summary when no interfaces have capacity", %{organization: organization} do + {:ok, empty_site} = + Towerops.Sites.create_site(%{name: "Empty Site", organization_id: organization.id}) + + summary = Capacity.get_site_capacity_summary(empty_site.id) + assert summary.total_capacity_bps == 0 + assert summary.interfaces == [] + end + end + + describe "get_organization_capacity_summary/1" do + test "returns per-site capacity summaries", %{organization: organization, site: site} do + summaries = Capacity.get_organization_capacity_summary(organization.id) + + assert summaries != [] + site_summary = Enum.find(summaries, &(&1.site_id == site.id)) + assert site_summary + assert site_summary.total_capacity_bps == 300_000_000 + end + end + + defp insert_interface_stat(interface_id, attrs) do + %InterfaceStat{} + |> InterfaceStat.changeset(Map.put(attrs, :interface_id, interface_id)) + |> Repo.insert!() + end +end diff --git a/test/towerops/snmp_test.exs b/test/towerops/snmp_test.exs index 67130b72..fac436e3 100644 --- a/test/towerops/snmp_test.exs +++ b/test/towerops/snmp_test.exs @@ -2882,4 +2882,40 @@ defmodule Towerops.SnmpTest do assert job.queue == "check_executors" end end + + describe "set_manual_capacity/2" do + test "sets manual capacity on an interface", %{snmp_device: snmp_device} do + interface = + %Interface{} + |> Interface.changeset(%{snmp_device_id: snmp_device.id, if_index: 1, if_name: "eth0"}) + |> Repo.insert!() + + {:ok, updated} = Snmp.set_manual_capacity(interface.id, 300_000_000) + assert updated.configured_capacity_bps == 300_000_000 + assert updated.capacity_source == "manual" + end + + test "returns error for non-existent interface" do + assert {:error, :not_found} = Snmp.set_manual_capacity(Ecto.UUID.generate(), 100_000) + end + end + + describe "clear_manual_capacity/1" do + test "clears capacity fields on an interface", %{snmp_device: snmp_device} do + interface = + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: snmp_device.id, + if_index: 1, + if_name: "eth0", + configured_capacity_bps: 300_000_000, + capacity_source: "manual" + }) + |> Repo.insert!() + + {:ok, updated} = Snmp.clear_manual_capacity(interface.id) + assert is_nil(updated.configured_capacity_bps) + assert is_nil(updated.capacity_source) + end + end end