towerops/lib/towerops_web/live/device_live/helpers/formatters.ex

287 lines
8.8 KiB
Elixir

defmodule ToweropsWeb.DeviceLive.Helpers.Formatters do
@moduledoc """
Formatting helper functions for DeviceLive.Show.
Pure functions that format values for display without side effects.
"""
@doc """
Formats a date to human-readable string.
Examples:
- DateTime ~U[2024-02-15 10:30:00Z] -> "February 15, 2024"
- nil -> ""
"""
@spec format_date(DateTime.t() | nil) :: String.t()
def format_date(nil), do: ""
def format_date(date) do
Calendar.strftime(date, "%B %-d, %Y")
end
@doc """
Formats a speed in bps to human-readable units (Kbps, Mbps, Gbps).
Examples:
- 1_500_000_000 -> "1.5 Gbps"
- 100_000_000 -> "100.0 Mbps"
- 512 -> "512 bps"
- nil -> "-"
"""
@spec format_speed(integer() | nil) :: String.t()
def format_speed(bps) when is_integer(bps) and bps >= 1_000_000_000, do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps"
def format_speed(bps) when is_integer(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps"
def format_speed(bps) when is_integer(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps"
def format_speed(bps) when is_integer(bps), do: "#{bps} bps"
def format_speed(_), do: "-"
@doc """
Formats bytes to human-readable units (KB, MB, GB, TB).
Examples:
- 1_073_741_824 -> "1.0 GB"
- 1_048_576 -> "1.0 MB"
- 0 -> "0 B"
- nil -> "-"
"""
@spec format_bytes(integer() | nil) :: String.t()
def format_bytes(nil), do: "-"
def format_bytes(0), do: "0 B"
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_099_511_627_776,
do: "#{Float.round(bytes / 1_099_511_627_776, 1)} TB"
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_073_741_824,
do: "#{Float.round(bytes / 1_073_741_824, 1)} GB"
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_048_576, do: "#{Float.round(bytes / 1_048_576, 1)} MB"
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1024, do: "#{Float.round(bytes / 1024, 1)} KB"
def format_bytes(bytes) when is_integer(bytes), do: "#{bytes} B"
def format_bytes(_), do: "-"
@doc """
Formats a sensor value (already divided by divisor).
Returns value as string with 1 decimal place.
"""
@spec format_sensor_value(number() | nil, any()) :: String.t()
def format_sensor_value(value, _divisor) when is_number(value) do
# Value is already divided by divisor when stored in sensor_reading
# Format as string to avoid scientific notation for large numbers
:erlang.float_to_binary(value * 1.0, decimals: 1)
end
def format_sensor_value(_, _), do: "-"
@doc """
Formats a location string, parsing GPS coordinates to 6 decimal places.
Examples:
- "40.7128, -74.0060" -> "40.712800, -74.006000"
- "New York" -> "New York"
- nil -> "N/A"
"""
@spec format_location(String.t() | nil) :: String.t()
def format_location(location) when is_binary(location) do
if gps_coordinates?(location) do
format_gps_coordinates(location)
else
location
end
end
def format_location(nil), do: "N/A"
def 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
@doc """
Formats a DateTime as "time ago" relative to now.
Examples:
- 30 seconds ago -> "30s ago"
- 5 minutes ago -> "5m ago"
- 2 hours ago -> "2h ago"
- 7 days ago -> "7d ago"
- nil -> "Never"
"""
@spec time_ago(DateTime.t() | nil) :: String.t()
def time_ago(nil), do: "Never"
def time_ago(datetime) do
DateTime.utc_now() |> DateTime.diff(datetime, :second) |> format_ago()
end
defp format_ago(diff) when diff < 60, do: "#{diff}s ago"
defp format_ago(diff) when diff < 3600, do: "#{div(diff, 60)}m ago"
defp format_ago(diff) when diff < 86_400, do: "#{div(diff, 3600)}h ago"
defp format_ago(diff), do: "#{div(diff, 86_400)}d ago"
@doc """
Formats SNMP uptime (in timeticks) to human-readable duration.
Timeticks are in hundredths of a second (centiseconds).
Examples:
- 8_640_000 (1 day) -> "1 day"
- 360_000 (1 hour) -> "1 hour"
- nil -> "N/A"
"""
@spec format_uptime(integer() | nil) :: String.t()
def format_uptime(nil), do: "N/A"
def format_uptime(timeticks) when is_integer(timeticks) do
# Timeticks are in hundredths of a second
seconds = div(timeticks, 100)
format_duration(seconds)
end
def format_uptime(_), do: "N/A"
@doc """
Formats a duration in seconds to human-readable string.
Examples:
- 86400 -> "1 day"
- 3661 -> "1 hour 1 minute"
- 45 -> "less than a minute"
"""
@spec format_duration(integer()) :: String.t()
def format_duration(total_seconds) do
{weeks, remainder} = seconds_to_weeks(total_seconds)
{days, remainder} = seconds_to_days(remainder)
{hours, remainder} = seconds_to_hours(remainder)
{minutes, _seconds} = seconds_to_minutes(remainder)
parts =
Enum.reject(
[
format_time_part(weeks, "week"),
format_time_part(days, "day"),
format_time_part(hours, "hour"),
format_time_part(minutes, "minute")
],
&is_nil/1
)
case parts do
[] -> "less than a minute"
parts -> Enum.join(parts, " ")
end
end
@spec seconds_to_weeks(integer()) :: {integer(), integer()}
defp seconds_to_weeks(seconds), do: {div(seconds, 604_800), rem(seconds, 604_800)}
@spec seconds_to_days(integer()) :: {integer(), integer()}
defp seconds_to_days(seconds), do: {div(seconds, 86_400), rem(seconds, 86_400)}
@spec seconds_to_hours(integer()) :: {integer(), integer()}
defp seconds_to_hours(seconds), do: {div(seconds, 3600), rem(seconds, 3600)}
@spec seconds_to_minutes(integer()) :: {integer(), integer()}
defp seconds_to_minutes(seconds), do: {div(seconds, 60), rem(seconds, 60)}
@spec format_time_part(integer(), String.t()) :: String.t() | nil
defp format_time_part(0, _unit), do: nil
defp format_time_part(1, unit), do: "1 #{unit}"
defp format_time_part(count, unit), do: "#{count} #{unit}s"
@doc """
Formats device age (time since created) as duration + "ago".
Examples:
- 1 day old -> "1 day ago"
- nil -> "N/A"
"""
@spec format_device_age(DateTime.t() | nil) :: String.t()
def format_device_age(nil), do: "N/A"
def format_device_age(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
format_duration(diff) <> " ago"
end
@doc """
Formats event type from snake_case to Title Case.
Examples:
- "interface_down" -> "Interface down"
- "sensor_threshold_exceeded" -> "Sensor threshold exceeded"
"""
@spec format_event_type(String.t()) :: String.t()
def format_event_type(event_type) do
event_type
|> String.replace("_", " ")
|> String.capitalize()
end
@doc """
Returns capacity source label for display.
"""
@spec capacity_source_label(String.t()) :: String.t()
def capacity_source_label("manual"), do: "Manual"
def capacity_source_label("sensor"), do: "Sensor"
def capacity_source_label("if_speed"), do: "Link"
def capacity_source_label("peak"), do: "Peak"
def capacity_source_label(_), do: ""
@doc """
Returns Tailwind CSS classes for capacity source badges.
"""
@spec capacity_source_class(String.t()) :: String.t()
def capacity_source_class("manual"), do: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
def capacity_source_class("sensor"), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
def capacity_source_class("if_speed"), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
def capacity_source_class("peak"), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300"
def capacity_source_class(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
@doc """
Returns background color class for utilization percentage.
- >= 90%: red
- >= 70%: yellow
- < 70%: green
"""
@spec utilization_color(number()) :: String.t()
def utilization_color(pct) when pct >= 90, do: "bg-red-500"
def utilization_color(pct) when pct >= 70, do: "bg-yellow-500"
def utilization_color(_pct), do: "bg-green-500"
@doc """
Returns text color class for utilization percentage.
- >= 90%: red
- >= 70%: yellow
- < 70%: green
"""
@spec utilization_text_color(number()) :: String.t()
def utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
def utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
def utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
end