diff --git a/lib/microwaveprop/propagation/pipeline_status.ex b/lib/microwaveprop/propagation/pipeline_status.ex new file mode 100644 index 00000000..af25ff9a --- /dev/null +++ b/lib/microwaveprop/propagation/pipeline_status.ex @@ -0,0 +1,113 @@ +defmodule Microwaveprop.Propagation.PipelineStatus do + @moduledoc """ + Aggregated status for the propagation update pipeline. + + The pipeline is driven by two Oban workers: + + * `PropagationGridWorker` — hourly, fetches HRRR f00–f18, scores + the CONUS grid, and broadcasts `propagation:updated`. + * `AsosAdjustmentWorker` — every 10 minutes, nudges scores with + recent ASOS surface observations. + + `current/0` returns one struct so the map page can render a single + status chip ("Updating…", "Up to date · 8m ago", "Stale · 4h ago") + that reflects the whole pipeline rather than tailing a single job. + """ + + import Ecto.Query + + alias Microwaveprop.Repo + + @grid_worker "Microwaveprop.Workers.PropagationGridWorker" + @asos_worker "Microwaveprop.Workers.AsosAdjustmentWorker" + + @workers [@grid_worker, @asos_worker] + + # Scores older than this flip the chip to :stale. Matches the + # FreshnessMonitor threshold — anything past 2h is already a gap + # the pipeline should have caught. + @stale_after_minutes 120 + + @type state :: :running | :idle | :stale | :unknown + + @type t :: %{ + state: state(), + label: String.t(), + last_update_at: DateTime.t() | nil + } + + @spec current() :: t() + def current do + case running_worker() do + worker when is_binary(worker) -> + %{ + state: :running, + label: running_label(worker), + last_update_at: latest_completed_at() + } + + nil -> + build_idle_or_stale(latest_completed_at()) + end + end + + defp running_worker do + Repo.one( + from j in "oban_jobs", + where: j.state == "executing" and j.worker in ^@workers, + order_by: [desc: j.attempted_at], + limit: 1, + select: j.worker + ) + end + + defp latest_completed_at do + case Repo.one( + from j in "oban_jobs", + where: j.state == "completed" and j.worker in ^@workers, + order_by: [desc: j.completed_at], + limit: 1, + select: j.completed_at + ) do + nil -> nil + %NaiveDateTime{} = naive -> DateTime.from_naive!(naive, "Etc/UTC") + %DateTime{} = dt -> dt + end + end + + defp build_idle_or_stale(nil) do + %{state: :unknown, label: "Propagation status unknown", last_update_at: nil} + end + + defp build_idle_or_stale(%DateTime{} = last) do + age_minutes = max(DateTime.diff(DateTime.utc_now(), last, :minute), 0) + + if age_minutes > @stale_after_minutes do + %{ + state: :stale, + label: "Propagation data stale · last update #{format_age(age_minutes)} ago", + last_update_at: last + } + else + %{ + state: :idle, + label: "Up to date · #{format_age(age_minutes)} ago", + last_update_at: last + } + end + end + + defp running_label(@grid_worker), do: "Updating propagation (HRRR run)…" + defp running_label(@asos_worker), do: "Updating propagation (ASOS nudge)…" + defp running_label(_), do: "Updating propagation…" + + defp format_age(0), do: "just now" + defp format_age(1), do: "1m" + defp format_age(n) when n < 60, do: "#{n}m" + + defp format_age(n) do + hours = div(n, 60) + mins = rem(n, 60) + if mins == 0, do: "#{hours}h", else: "#{hours}h #{mins}m" + end +end diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 8d441060..c3109988 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.MapLive do alias Microwaveprop.Propagation alias Microwaveprop.Propagation.BandConfig + alias Microwaveprop.Propagation.PipelineStatus alias Microwaveprop.Terrain.Viewshed require Logger @@ -14,13 +15,22 @@ defmodule MicrowavepropWeb.MapLive do @default_center %{lat: 32.897, lon: -97.038} @default_zoom 7 + # How often to re-query oban_jobs for pipeline state while the map is + # open. Short enough that the "Updating…" chip shows up within a page- + # visit for an in-progress hourly run, long enough to be cheap. + @pipeline_status_refresh_ms 15_000 + @impl true def mount(_params, session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated") + Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms) end - socket = assign(socket, :initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC")) + socket = + socket + |> assign(:initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC")) + |> assign(:pipeline_status, PipelineStatus.current()) # LiveStash only persists the keys passed to `stash_assigns/2` # (selected_band + selected_time). On reconnect the recovered socket has @@ -264,12 +274,18 @@ defmodule MicrowavepropWeb.MapLive do socket = socket |> assign(valid_times: valid_times, selected_time: selected_time) + |> assign(:pipeline_status, PipelineStatus.current()) |> push_event("update_scores", %{scores: scores}) |> push_timeline() {:noreply, socket} end + def handle_info(:refresh_pipeline_status, socket) do + Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms) + {:noreply, assign(socket, :pipeline_status, PipelineStatus.current())} + end + @impl true def handle_async(:viewshed, {:ok, result}, socket) do points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end) @@ -344,6 +360,38 @@ defmodule MicrowavepropWeb.MapLive do end end + attr :id, :string, required: true + attr :status, :map, required: true + + defp pipeline_status_chip(assigns) do + ~H""" +