diff --git a/lib/microwaveprop/format.ex b/lib/microwaveprop/format.ex index 36864508..e2a94a4c 100644 --- a/lib/microwaveprop/format.ex +++ b/lib/microwaveprop/format.ex @@ -27,4 +27,34 @@ defmodule Microwaveprop.Format do end defp one_decimal(n), do: :erlang.float_to_binary(n * 1.0, decimals: 1) + + @doc """ + Thousand-grouped integer for UI counters (row counts, job totals, etc). + Floats round to nearest integer first; non-numerics are passed through + to `to_string/1` so a `nil` or `:unknown` renders without crashing. + """ + @spec number(integer() | float() | any()) :: String.t() + def number(n) when is_integer(n) do + n + |> Integer.to_string() + |> String.graphemes() + |> Enum.reverse() + |> Enum.chunk_every(3) + |> Enum.map_join(",", &Enum.join/1) + |> String.reverse() + |> String.replace(~r/^,/, "") + end + + def number(n) when is_float(n), do: number(round(n)) + def number(n), do: to_string(n) + + @doc """ + Binary-prefix byte count (1024-based) with one decimal. Intended for + admin / status surfaces where "1.4 MB" is more useful than "1468006". + """ + @spec bytes(non_neg_integer()) :: String.t() + def bytes(b) when b >= 1_073_741_824, do: "#{Float.round(b / 1_073_741_824, 1)} GB" + def bytes(b) when b >= 1_048_576, do: "#{Float.round(b / 1_048_576, 1)} MB" + def bytes(b) when b >= 1024, do: "#{Float.round(b / 1024, 1)} KB" + def bytes(b), do: "#{b} B" end diff --git a/lib/microwaveprop/workers/weather_fetch_worker.ex b/lib/microwaveprop/workers/weather_fetch_worker.ex index ae364587..34999515 100644 --- a/lib/microwaveprop/workers/weather_fetch_worker.ex +++ b/lib/microwaveprop/workers/weather_fetch_worker.ex @@ -84,12 +84,12 @@ defmodule Microwaveprop.Workers.WeatherFetchWorker do end end - # IEM rate-limits heavy ASOS pulls with HTTP 429. That's routine - # during a backfill sweep, not a job failure — snooze the job - # instead of surfacing Oban.PerformError, which spams the logger - # with a full exception per 429 even though the retry is working - # as intended. Other statuses (5xx, connection errors) stay on the - # {:error, _} path so they count against max_attempts. + # IEM upstream is routinely flaky — HTTP 429 rate-limits during a + # backfill sweep, and transient connection resets (Req.TransportError + # :closed / :timeout) on long-running fetches. Neither is a job + # failure worth Oban.PerformError's stack-trace; snooze instead so + # the retry is silent. Permanent errors (4xx ≠ 429, 5xx) fall through + # to the warning + {:error, _} path so they count toward max_attempts. defp handle_iem_error(kind, station_code, "IEM " <> _ = reason) do if String.contains?(reason, "429") do Logger.debug("WeatherFetch #{kind}: #{station_code} rate-limited, snoozing") @@ -100,8 +100,15 @@ defmodule Microwaveprop.Workers.WeatherFetchWorker do end end + defp handle_iem_error(kind, station_code, %Req.TransportError{reason: tr_reason}) + when tr_reason in [:closed, :timeout, :econnrefused, :nxdomain] do + Logger.debug("WeatherFetch #{kind}: #{station_code} transport #{tr_reason}, snoozing") + {:snooze, 60} + end + defp handle_iem_error(kind, station_code, reason) do Logger.warning("WeatherFetch #{kind}: #{station_code} failed: #{inspect(reason, printable_limit: 200)}") + {:error, reason} end diff --git a/lib/microwaveprop_web/live/status_live.ex b/lib/microwaveprop_web/live/status_live.ex index cd758f73..e0b1236a 100644 --- a/lib/microwaveprop_web/live/status_live.ex +++ b/lib/microwaveprop_web/live/status_live.ex @@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.StatusLive do import Ecto.Query alias Microwaveprop.Cache + alias Microwaveprop.Format alias Microwaveprop.Radio.Contact alias Microwaveprop.Repo alias Microwaveprop.Weather.NarrClient @@ -352,7 +353,7 @@ defmodule MicrowavepropWeb.StatusLive do tables = Enum.map(rows, fn [table, row_count, dead, bytes] -> - %{table: table, rows: row_count, dead: dead, size: format_bytes(bytes)} + %{table: table, rows: row_count, dead: dead, size: Format.bytes(bytes)} end) %{rows: total_rows} = @@ -409,7 +410,7 @@ defmodule MicrowavepropWeb.StatusLive do contact_count = max(Map.get(table_counts, "contacts", 0), 1) %{ - db_size: format_bytes(db_bytes), + db_size: Format.bytes(db_bytes), tables: tables, statuses: statuses, totals: %{ @@ -429,20 +430,6 @@ defmodule MicrowavepropWeb.StatusLive do } end - defp format_number(n) when is_integer(n) do - n - |> Integer.to_string() - |> String.graphemes() - |> Enum.reverse() - |> Enum.chunk_every(3) - |> Enum.map_join(",", &Enum.join/1) - |> String.reverse() - |> String.replace(~r/^,/, "") - end - - defp format_number(n) when is_float(n), do: format_number(round(n)) - defp format_number(n), do: to_string(n) - defp status_class("complete"), do: "text-success" defp status_class("pending"), do: "text-warning" defp status_class("queued"), do: "text-info" @@ -451,14 +438,6 @@ defmodule MicrowavepropWeb.StatusLive do defp status_class("unavailable"), do: "opacity-40" defp status_class(_), do: "" - defp format_bytes(bytes) when bytes >= 1_073_741_824, do: "#{Float.round(bytes / 1_073_741_824, 1)} GB" - - defp format_bytes(bytes) when bytes >= 1_048_576, do: "#{Float.round(bytes / 1_048_576, 1)} MB" - - defp format_bytes(bytes) when bytes >= 1024, do: "#{Float.round(bytes / 1024, 1)} KB" - - defp format_bytes(bytes), do: "#{bytes} B" - @impl true def render(assigns) do ~H""" @@ -472,7 +451,7 @@ defmodule MicrowavepropWeb.StatusLive do
Enrichment Progress
- {format_number(@unprocessed.complete)} / {format_number(@unprocessed.total)} + {Format.number(@unprocessed.complete)} / {Format.number(@unprocessed.total)}
fully enriched
@@ -493,7 +472,7 @@ defmodule MicrowavepropWeb.StatusLive do max={100} /> - {format_number(complete)} / {format_number(denom)} + {Format.number(complete)} / {Format.number(denom)}
<% end %> @@ -508,7 +487,7 @@ defmodule MicrowavepropWeb.StatusLive do
Completed (last hour)
-
{format_number(@stats.completed_1h)}
+
{Format.number(@stats.completed_1h)}
@@ -599,7 +578,7 @@ defmodule MicrowavepropWeb.StatusLive do 0 <% end %> - {format_number(@grid_tasks.done_1h)} + {Format.number(@grid_tasks.done_1h)} hrrr-point-rs @@ -614,7 +593,7 @@ defmodule MicrowavepropWeb.StatusLive do <% end %> - {format_number(@hrrr_point_tasks.queued)} + {Format.number(@hrrr_point_tasks.queued)} <%= if @hrrr_point_tasks.failed > 0 do %> {@hrrr_point_tasks.failed} @@ -622,7 +601,7 @@ defmodule MicrowavepropWeb.StatusLive do 0 <% end %> - {format_number(@hrrr_point_tasks.done_1h)} + {Format.number(@hrrr_point_tasks.done_1h)} @@ -636,7 +615,7 @@ defmodule MicrowavepropWeb.StatusLive do <%= for {status, count} <- Map.get(@db_stats.statuses, type, []) do %>
{status} - {format_number(count)} + {Format.number(count)}
<% end %> @@ -652,21 +631,21 @@ defmodule MicrowavepropWeb.StatusLive do
HRRR Profiles
- {format_number(@db_stats.totals.hrrr_profiles)} + {Format.number(@db_stats.totals.hrrr_profiles)}
{@db_stats.averages.hrrr_per_contact} per contact
Surface Observations
- {format_number(@db_stats.totals.surface_observations)} + {Format.number(@db_stats.totals.surface_observations)}
{@db_stats.averages.obs_per_contact} per contact
Soundings
- {format_number(@db_stats.totals.soundings)} + {Format.number(@db_stats.totals.soundings)}
{@db_stats.averages.soundings_per_contact} per contact
@@ -687,12 +666,12 @@ defmodule MicrowavepropWeb.StatusLive do <%= for t <- @db_stats.tables do %> {t.table} - {format_number(t.rows)} + {Format.number(t.rows)} 0 && "text-warning" ]}> - {format_number(t.dead)} + {Format.number(t.dead)} {t.size}