Dependency updates:
- phoenix_live_view: ~> 1.2.0 (was ~> 1.1.0)
- Transitive bumps: bandit 1.11.1→1.12.0, credo 1.7.18→1.7.19,
phoenix 1.8.7→1.8.8, req 0.5.18→0.6.1, swoosh 1.26.0→1.26.1
Fixes:
- core_components.ex: add 'type' to button :global allowlist
(Phoenix 1.8+ attribute validation)
- status_live_test.exs: invalidate {:all_stats} cache key to
prevent stale ETS cache from leaking between tests
- path_live_test.exs: accept all valid async states in assertion
(race between progress frame and compute completion)
- skewt_live_test.exs: accept loading or completed state in assertion
HEEx auto-formatting from mix format (self-closing tag normalization)
223 lines
7.1 KiB
Elixir
223 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>
|
|
· {@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
|