Promoted pure presentation and utility helpers from `defp` to `def @doc false` across ~20 LiveViews, Oban workers, and sync modules so they're reachable from unit tests. Refactored several `cond` blocks into idiomatic function heads with guards. Added ~250 new test cases in new files under test/towerops and test/towerops_web, including DB-backed tests for CnMaestro.Sync and AlertNotificationWorker, and removed dead LiveView tab components and CapacityLive (no callers anywhere in lib/test). Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types, Phoenix HTML modules, Inspect protocol impls) from coverage calculations — these are not our project code. Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
287 lines
8.8 KiB
Elixir
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
|