prop/lib/microwaveprop/propagation/pipeline_status.ex
Graham McIntire 554bc9f339
Show aggregated propagation pipeline status on the map
Adds a small chip below the NTMS title in both the mobile and
desktop sidebars of /map that collapses PropagationGridWorker +
AsosAdjustmentWorker Oban state into one of four states —
running / idle / stale / unknown — with a plain-language label
("Updating propagation (HRRR run)…", "Up to date · 12m ago").
The chip refreshes on a 15s timer and whenever the pipeline
broadcasts propagation:updated, so a long HRRR sweep is visible
from the moment a visitor opens the page.
2026-04-14 10:01:34 -05:00

113 lines
3.2 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 f00f18, 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