fix: prevent scientific notation in sensor value display (#114)

Radio frequencies (24 GHz) were displaying as "2.42e4 MHz" instead of
"24200.0 MHz" because Float.round/2 returns a float that Phoenix
converts to scientific notation for large numbers.

Changed format_sensor_value/2 to use :erlang.float_to_binary/2 with
decimals: 1 option, which formats as a string and avoids scientific
notation.

Verified: 24200.0 now displays as "24200.0" instead of "2.42e4"

Reviewed-on: graham/towerops-web#114
This commit is contained in:
Graham McIntire 2026-03-22 14:23:18 -05:00 committed by graham
parent 2bb040154a
commit 3e89045f76
2 changed files with 70 additions and 20 deletions

View file

@ -873,11 +873,42 @@ defmodule ToweropsWeb.DeviceLive.Show do
defp format_sensor_value(value, _divisor) when is_number(value) do
# Value is already divided by divisor when stored in sensor_reading
Float.round(value, 1)
# Format as string to avoid scientific notation for large numbers
:erlang.float_to_binary(value * 1.0, decimals: 1)
end
defp format_sensor_value(_, _), do: "-"
# Format GPS coordinates to 6 decimal places (standard precision, ~0.1m accuracy)
defp format_location(location) when is_binary(location) do
if gps_coordinates?(location) do
format_gps_coordinates(location)
else
location
end
end
defp format_location(nil), do: "N/A"
defp format_location(_), do: "N/A"
defp gps_coordinates?(location) do
String.contains?(location, ",") && String.match?(location, ~r/[-\d.]+,\s*[-\d.]+/)
end
defp format_gps_coordinates(location) do
location
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map_join(", ", &format_coordinate/1)
end
defp format_coordinate(coord) do
case Float.parse(coord) do
{num, _} -> :erlang.float_to_binary(num, decimals: 6)
:error -> coord
end
end
defp time_ago(nil), do: "Never"
defp time_ago(datetime) do
@ -1232,7 +1263,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
# For backhaul devices, add capacity reference lines
datasets =
if device.device.device_role == "backhaul" do
add_capacity_datasets(datasets, all_stats, device.interfaces)
add_capacity_datasets(datasets, all_stats, device)
else
datasets
end
@ -1242,14 +1273,21 @@ defmodule ToweropsWeb.DeviceLive.Show do
# Add capacity reference lines for backhaul devices
# Calculates capacity per timestamp based on which interfaces have data at that point
defp add_capacity_datasets(datasets, all_stats, interfaces) do
# Build a map of interface_id -> capacity for quick lookup
# For radios with Rx/Tx Capacity sensors, uses those values instead of if_speed
defp add_capacity_datasets(datasets, all_stats, device) do
# Get capacity sensors if available (for radios like AirFiber)
rx_capacity_sensor = Enum.find(device.sensors, &(&1.sensor_descr == "Rx Capacity"))
tx_capacity_sensor = Enum.find(device.sensors, &(&1.sensor_descr == "Tx Capacity"))
# Build a map of interface_id -> {rx_capacity, tx_capacity} for quick lookup
interface_capacity_map =
interfaces
device.interfaces
|> Enum.filter(& &1.monitored)
|> Map.new(fn interface ->
capacity = interface.configured_capacity_bps || interface.if_speed || 0
{interface.id, capacity}
# Use sensor values if available (converted to bps), otherwise fall back to configured/if_speed
rx_cap = get_capacity_bps(rx_capacity_sensor) || interface.configured_capacity_bps || interface.if_speed || 0
tx_cap = get_capacity_bps(tx_capacity_sensor) || interface.configured_capacity_bps || interface.if_speed || 0
{interface.id, {rx_cap, tx_cap}}
end)
# Get the raw interface stats to determine which interfaces were active at each timestamp
@ -1257,7 +1295,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
# Track which interfaces have data at each timestamp
interface_activity_by_timestamp =
interfaces
device.interfaces
|> Enum.flat_map(fn interface ->
interface.id
|> Snmp.get_interface_stats(since: twenty_four_hours_ago, limit: 1000)
@ -1270,34 +1308,36 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> Enum.group_by(fn {ts, _} -> ts end, fn {_, iface_id} -> iface_id end)
# Calculate capacity at each timestamp based on active interfaces
# For backhaul devices with Rx/Tx sensors, track separately
capacity_data =
Enum.map(all_stats, fn stat ->
# Get interfaces that had data at this timestamp
active_interfaces = Map.get(interface_activity_by_timestamp, stat.timestamp_ms, [])
# Sum capacity of active interfaces
total_capacity =
{total_rx, total_tx} =
active_interfaces
|> Enum.uniq()
|> Enum.reduce(0, fn iface_id, acc ->
capacity = Map.get(interface_capacity_map, iface_id, 0)
acc + capacity
|> Enum.reduce({0, 0}, fn iface_id, {rx_acc, tx_acc} ->
{rx_cap, tx_cap} = Map.get(interface_capacity_map, iface_id, {0, 0})
{rx_acc + rx_cap, tx_acc + tx_cap}
end)
{stat.timestamp_ms, total_capacity}
{stat.timestamp_ms, total_rx, total_tx}
end)
# Only add capacity datasets if we have at least some capacity data
max_capacity = capacity_data |> Enum.map(fn {_, cap} -> cap end) |> Enum.max(fn -> 0 end)
max_rx = capacity_data |> Enum.map(fn {_, rx, _} -> rx end) |> Enum.max(fn -> 0 end)
max_tx = capacity_data |> Enum.map(fn {_, _, tx} -> tx end) |> Enum.max(fn -> 0 end)
if max_capacity > 0 do
if max_rx > 0 || max_tx > 0 do
capacity_in_data =
Enum.map(capacity_data, fn {ts, cap} ->
%{x: ts, y: Float.round(cap * 1.0, 2)}
Enum.map(capacity_data, fn {ts, rx, _tx} ->
%{x: ts, y: Float.round(rx * 1.0, 2)}
end)
capacity_out_data =
Enum.map(capacity_data, fn {ts, cap} ->
%{x: ts, y: -Float.round(cap * 1.0, 2)}
Enum.map(capacity_data, fn {ts, _rx, tx} ->
%{x: ts, y: -Float.round(tx * 1.0, 2)}
end)
# Add capacity datasets with special styling
@ -1325,6 +1365,16 @@ defmodule ToweropsWeb.DeviceLive.Show do
end
end
# Get capacity in bps from a sensor (sensor values are in Mbps, convert to bps)
defp get_capacity_bps(nil), do: nil
defp get_capacity_bps(sensor) do
if sensor.latest_reading && sensor.latest_reading.value do
# Sensor value is in Mbps, convert to bps
round(sensor.latest_reading.value * 1_000_000)
end
end
# Group interfaces by type for organized display
defp group_interfaces_by_type(interfaces) do
interfaces

View file

@ -555,7 +555,7 @@
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Location</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@snmp_device.sys_location || "N/A"}
{format_location(@snmp_device.sys_location)}
</dd>
</div>