prop/lib/microwaveprop_web/live/import_live.ex
Graham McIntire d61fbd346e
fix(dialyzer): clear 125+ warnings under strict flags
Enabled :error_handling, :unknown, :unmatched_returns, :extra_return,
:missing_return in an earlier commit and landed a 129-warning baseline.
Four parallel agents each fixed a directory slice:

- Core contexts (29): Radio, Release, Weather, Beacons, Cache,
  Backtest.Features, Terrain.Srtm, Ionosphere.GiroClient,
  Propagation.RunTiming, Accounts.Scope, RepoListener. Fixes were
  (a) prefix side-effect calls (Task.start, Phoenix.PubSub,
  Logger, :ets.new) with _ = ; (b) tighten/widen specs that didn't
  match actual returns; (c) add missing @type t declarations;
  (d) drop dead parse_int(nil) clause.

- Propagation + weather subdirs (15): FreshnessMonitor, NotifyListener,
  ScoreCache, ScoreCacheReconciler, Weather.FrontalAnalysis,
  Weather.Grib2.Extractor, Weather.Grib2.Wgrib2, GridCache,
  HrrrPointEnqueuer, NexradCache. Same patterns — mostly _ = on
  PubSub / :ets / Repo.insert_all; widened two specs (float ->
  number) where integer returns were reachable.

- Workers (35): BackfillEnqueue, CanadianSoundingFetch,
  ContactImport, ContactWeatherEnqueue, GefsFetch, IemreFetch,
  NarrFetch, SolarIndex, TerrainProfile, WeatherFetch. Prefixed
  Repo.update_all / Radio.set_enrichment_status! / Weather.upsert_*
  side-effect calls. Fixed one :pattern_match in
  CanadianSoundingFetch.most_recent_sounding_time/1 where a
  tautological cond guard generated unreachable code.

- Web + Mix tasks + lib_ml (46 of 50): controllers, LiveViews,
  UserAuth, and 11 mix tasks. Same prefix strategy. 4 remaining
  warnings originate in LiveTable.LiveResource dep macro expansion
  and can't be fixed without forking the dep — added .dialyzer_ignore.exs
  to suppress just those specific file:line pairs.

Also wired ignore_warnings in mix.exs dialyzer config.

mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2163 tests, 3 pre-existing flakes, 0 regressions.
2026-04-21 10:30:06 -05:00

224 lines
7.1 KiB
Elixir

defmodule MicrowavepropWeb.ImportLive do
@moduledoc """
Shows live progress for an async CSV/ADIF contact import.
Mounts on `/imports/:id`, loads the `ImportRun`, and subscribes to the
`"csv_import:<id>"` PubSub topic so counters update as
`ContactImportWorker` chunks finish.
"""
use MicrowavepropWeb, :live_view
alias Microwaveprop.Radio.ImportRun
alias Microwaveprop.Repo
@pubsub Microwaveprop.PubSub
@impl true
def mount(%{"id" => id}, _session, socket) do
case load_run(id) do
nil ->
{:ok,
socket
|> put_flash(:error, "Import not found.")
|> push_navigate(to: ~p"/submit")}
%ImportRun{} = run ->
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(@pubsub, "csv_import:#{run.id}")
end
{:ok,
socket
|> assign(:page_title, "Import ##{String.slice(run.id, 0, 8)}")
|> assign(:run, run)}
end
end
@impl true
def handle_info({:import_progress, _id, %ImportRun{} = updated}, socket) do
{:noreply, assign(socket, :run, updated)}
end
defp load_run(id) do
if valid_uuid?(id), do: Repo.get(ImportRun, id)
end
defp valid_uuid?(id) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, _} -> true
:error -> false
end
end
defp valid_uuid?(_), do: false
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Contact Import
<:subtitle>
Submitted by <span class="font-mono">{@run.submitter_email}</span>
&middot; {@run.total_rows} {pluralize(@run.total_rows, "row")}
</:subtitle>
<:actions>
<span id="import-status-badge" class={["badge badge-lg", status_badge_class(@run.status)]}>
{status_label(@run.status)}
</span>
</:actions>
</.header>
<div class="space-y-6">
<div>
<div class="flex items-center justify-between text-sm mb-1 tabular-nums">
<span>
{@run.processed_rows} / {@run.total_rows} processed
</span>
<span>{progress_percent(@run)}%</span>
</div>
<progress
id="import-progress-bar"
class={["progress w-full", progress_class(@run.status)]}
value={@run.processed_rows}
max={max(@run.total_rows, 1)}
>
</progress>
</div>
<div class="stats stats-vertical sm:stats-horizontal shadow w-full">
<div class="stat">
<div class="stat-title">Imported</div>
<div id="import-imported-count" class="stat-value text-success tabular-nums">
{@run.imported_count}
</div>
<div class="stat-desc">New contacts inserted</div>
</div>
<div class="stat">
<div class="stat-title">Refined</div>
<div id="import-refined-count" class="stat-value text-info tabular-nums">
{@run.refined_count}
</div>
<div class="stat-desc">Existing contacts updated</div>
</div>
<div class="stat">
<div class="stat-title">Errors</div>
<div id="import-error-count" class="stat-value text-error tabular-nums">
{@run.error_count}
</div>
<div class="stat-desc">Rows that failed at insert</div>
</div>
</div>
<div
:if={@run.status == "complete"}
id="import-complete-panel"
class="alert alert-success"
phx-hook="ImportConfetti"
>
<.icon name="hero-check-circle" class="w-6 h-6" />
<div>
<div class="font-semibold">Import complete</div>
<div class="text-sm opacity-90">
Enrichment (weather, terrain, propagation) will continue in the background.
</div>
</div>
</div>
<div
:if={@run.status == "failed"}
id="import-failed-panel"
class="alert alert-error"
>
<.icon name="hero-exclamation-triangle" class="w-6 h-6" />
<span>Import failed before processing all rows.</span>
</div>
<details
:if={@run.error_count > 0}
id="import-errors-section"
class="rounded-box border border-base-300"
>
<summary class="cursor-pointer p-4 font-semibold">
{@run.error_count} {pluralize(@run.error_count, "row")} with errors
</summary>
<div class="overflow-x-auto p-4 pt-0">
<table class="table table-sm">
<thead>
<tr>
<th class="w-24">Row</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
<tr :for={{row_num, messages} <- sorted_errors(@run.errors)}>
<td class="font-mono">Row {row_num}</td>
<td>
<div :for={msg <- messages}>{msg}</div>
</td>
</tr>
</tbody>
</table>
</div>
</details>
<div class="flex flex-wrap gap-2 pt-4 border-t border-base-300">
<.link navigate={~p"/submit"} class="btn btn-outline">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Upload another
</.link>
<.link
:if={@run.status == "complete"}
navigate={~p"/contacts"}
class="btn btn-primary"
>
View contacts <.icon name="hero-arrow-right" class="w-4 h-4" />
</.link>
</div>
</div>
</Layouts.app>
"""
end
defp status_label("pending"), do: "Pending"
defp status_label("running"), do: "Running"
defp status_label("complete"), do: "Complete"
defp status_label("failed"), do: "Failed"
defp status_label(other), do: to_string(other)
defp status_badge_class("pending"), do: "badge-neutral"
defp status_badge_class("running"), do: "badge-info"
defp status_badge_class("complete"), do: "badge-success"
defp status_badge_class("failed"), do: "badge-error"
defp status_badge_class(_), do: "badge-neutral"
defp progress_class("complete"), do: "progress-success"
defp progress_class("failed"), do: "progress-error"
defp progress_class("running"), do: "progress-info"
defp progress_class(_), do: "progress-primary"
defp progress_percent(%ImportRun{total_rows: 0}), do: 0
defp progress_percent(%ImportRun{total_rows: total, processed_rows: processed}) do
min(100, round(processed * 100 / total))
end
defp pluralize(1, word), do: word
defp pluralize(_, word), do: word <> "s"
# `errors` is a JSONB map of "row_num" (string) → [messages]. Sort by
# numeric row number for display; fall back to string order if a key
# can't be parsed as an integer.
defp sorted_errors(nil), do: []
defp sorted_errors(errors) when is_map(errors) do
errors
|> Enum.map(fn {row_num, messages} -> {row_num, List.wrap(messages)} end)
|> Enum.sort_by(fn {row_num, _} ->
case Integer.parse(to_string(row_num)) do
{n, _} -> n
:error -> :infinity
end
end)
end
end