feat: add backhaul capacity analysis foundation
Add per-interface capacity tracking with manual override and automatic detection from SNMP sensors. Includes capacity resolver with priority cascade (manual > sensor > if_speed), throughput calculation from interface stats, and site/organization-level capacity summaries. - Migration: add configured_capacity_bps and capacity_source to snmp_interfaces - Capacity.Resolver: multi-source capacity detection with vendor unit normalization - Capacity context: throughput calculation, utilization percentages, site/org summaries - Snmp context: set_manual_capacity/2 and clear_manual_capacity/1 functions
This commit is contained in:
parent
480f8b17c5
commit
3913981094
8 changed files with 878 additions and 1 deletions
177
lib/towerops/capacity.ex
Normal file
177
lib/towerops/capacity.ex
Normal file
|
|
@ -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
|
||||
98
lib/towerops/capacity/resolver.ex
Normal file
98
lib/towerops/capacity/resolver.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
255
test/towerops/capacity/resolver_test.exs
Normal file
255
test/towerops/capacity/resolver_test.exs
Normal file
|
|
@ -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
|
||||
263
test/towerops/capacity_test.exs
Normal file
263
test/towerops/capacity_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue