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:
Graham McIntire 2026-03-06 17:54:32 -06:00
parent 480f8b17c5
commit 3913981094
No known key found for this signature in database
8 changed files with 878 additions and 1 deletions

177
lib/towerops/capacity.ex Normal file
View 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

View 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

View file

@ -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.
"""

View file

@ -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

View file

@ -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

View 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

View 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

View file

@ -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