diff --git a/lib/towerops/capacity.ex b/lib/towerops/capacity.ex index 6ea78ed5..9d5e0bb8 100644 --- a/lib/towerops/capacity.ex +++ b/lib/towerops/capacity.ex @@ -166,7 +166,18 @@ defmodule Towerops.Capacity do defp calculate_bps(prev_octets, curr_octets, time_diff) do delta = curr_octets - prev_octets - # Clamp negative deltas to 0 (counter wrap) + + # When prev is in the upper half of 32-bit range and delta is negative, + # the 32-bit counter likely wrapped around 2^32. Add 2^32 to recover + # the correct delta. If it's still negative after the correction, it's + # a device reboot/reset on 64-bit HC counters — clamp to 0. + delta = + if delta < 0 and prev_octets > 2_147_483_648 do + delta + 4_294_967_296 + else + delta + end + max(0.0, delta * 8 / time_diff) end diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index e5141289..12bad1fc 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -1242,14 +1242,14 @@ defmodule ToweropsWeb.DeviceLive.Show do in_bps = if s2.if_in_octets && s1.if_in_octets do - max((s2.if_in_octets - s1.if_in_octets) * 8 / time_diff, 0) + calculate_counter_bps(s1.if_in_octets, s2.if_in_octets, time_diff) else 0.0 end out_bps = if s2.if_out_octets && s1.if_out_octets do - max((s2.if_out_octets - s1.if_out_octets) * 8 / time_diff, 0) + calculate_counter_bps(s1.if_out_octets, s2.if_out_octets, time_diff) else 0.0 end @@ -1262,6 +1262,19 @@ defmodule ToweropsWeb.DeviceLive.Show do end) end + defp calculate_counter_bps(prev_octets, curr_octets, time_diff) do + delta = curr_octets - prev_octets + + delta = + if delta < 0 and prev_octets > 2_147_483_648 do + delta + 4_294_967_296 + else + delta + end + + max(0.0, delta * 8 / time_diff) + end + defp build_traffic_json_datasets(all_stats, device) do in_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: Float.round(s.in_bps, 2)} end) out_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: -Float.round(s.out_bps, 2)} end) diff --git a/test/towerops/capacity_test.exs b/test/towerops/capacity_test.exs index 18e37dab..45933514 100644 --- a/test/towerops/capacity_test.exs +++ b/test/towerops/capacity_test.exs @@ -83,7 +83,7 @@ defmodule Towerops.CapacityTest do assert_in_delta result.max_bps, 133_333.33, 1.0 end - test "clamps negative deltas to zero (counter wrap)", %{interface: interface} do + test "clamps to zero for small counter decreases (device reset or glitch)", %{interface: interface} do now = DateTime.utc_now() t1 = DateTime.add(now, -120, :second) t2 = DateTime.add(now, -60, :second) @@ -94,7 +94,7 @@ defmodule Towerops.CapacityTest do checked_at: t1 }) - # Counter wrapped - lower value than previous + # Small decrease - treated as reset/glitch, not a 32-bit wrap insert_interface_stat(interface.id, %{ if_in_octets: 1_000_000, if_out_octets: 6_000_000, @@ -103,11 +103,62 @@ defmodule Towerops.CapacityTest do 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 "recovers correct rate when 32-bit counter wraps", %{interface: interface} do + now = DateTime.utc_now() + t1 = DateTime.add(now, -120, :second) + t2 = DateTime.add(now, -60, :second) + + # Counter was near the 32-bit maximum (2^32 = 4,294,967,296) + insert_interface_stat(interface.id, %{ + if_in_octets: 4_290_000_000, + if_out_octets: 4_290_000_000, + checked_at: t1 + }) + + # Counter wrapped around — now at a small value + insert_interface_stat(interface.id, %{ + if_in_octets: 100_000_000, + if_out_octets: 100_000_000, + checked_at: t2 + }) + + result = Capacity.calculate_throughput(interface.id, DateTime.add(now, -180, :second)) + + # Correct delta: (4,294,967,296 - 4,290,000,000) + 100,000,000 = 104,967,296 bytes in 60s + # bps = 104,967,296 * 8 / 60 = 13,995,639 bps + assert_in_delta result.in_bps, 13_995_639.0, 1.0 + assert_in_delta result.out_bps, 13_995_639.0, 1.0 + end + + test "clamps to zero when HC 64-bit counter resets after device reboot", %{interface: interface} do + now = DateTime.utc_now() + t1 = DateTime.add(now, -120, :second) + t2 = DateTime.add(now, -60, :second) + + # Large 64-bit HC counter value (device had transferred 10 TB) + insert_interface_stat(interface.id, %{ + if_in_octets: 10_000_000_000_000, + if_out_octets: 10_000_000_000_000, + checked_at: t1 + }) + + # Device rebooted — counter reset to small value + insert_interface_stat(interface.id, %{ + if_in_octets: 1_000_000, + if_out_octets: 1_000_000, + checked_at: t2 + }) + + result = Capacity.calculate_throughput(interface.id, DateTime.add(now, -180, :second)) + + assert result.in_bps == 0.0 + assert result.out_bps == 0.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