Renames the rover-location status enum and every label that referenced it. Existing rows are migrated in place. Also touches the consumers: - Rover.Location enum + default - RoverLocationsLive index, status filter, form, show, badges - Rover.Compute and rover_live (only_ideal_locations → only_good_locations) - RoverPlanning context candidate filter (status == :good) - RoverPlanning.Show / Form copy - All rover-related tests
264 lines
8.9 KiB
Elixir
264 lines
8.9 KiB
Elixir
defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
|
@moduledoc """
|
|
`/rover-planning/:id` mission detail. Renders the planned stations
|
|
and the matrix of computed (or pending) terrain path profiles from
|
|
every candidate rover-location.
|
|
"""
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Accounts.User
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
alias Microwaveprop.RoverPlanning
|
|
alias Microwaveprop.RoverPlanning.Mission
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
case RoverPlanning.get_mission_with_paths(id) do
|
|
nil ->
|
|
{:ok,
|
|
socket
|
|
|> put_flash(:error, "Mission not found.")
|
|
|> push_navigate(to: ~p"/rover-planning")}
|
|
|
|
%Mission{} = mission ->
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "rover_planning:#{mission.id}")
|
|
end
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "Mission · #{mission.name}",
|
|
mission: mission,
|
|
paths: RoverPlanning.list_paths(mission)
|
|
)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:rover_path_updated, _path_id}, socket) do
|
|
{:noreply, assign(socket, paths: RoverPlanning.list_paths(socket.assigns.mission))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("delete", _params, socket) do
|
|
case current_user(socket) do
|
|
%User{} = user ->
|
|
case RoverPlanning.delete_mission(user, socket.assigns.mission.id) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "Mission deleted.")
|
|
|> push_navigate(to: ~p"/rover-planning")}
|
|
|
|
{:error, :not_found} ->
|
|
{:noreply, put_flash(socket, :error, "You can only delete your own missions.")}
|
|
end
|
|
|
|
_ ->
|
|
{:noreply, put_flash(socket, :error, "Sign in required.")}
|
|
end
|
|
end
|
|
|
|
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}) do
|
|
case assigns[:current_scope] do
|
|
%{user: %User{} = user} -> user
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp can_modify?(%{current_scope: %{user: %User{is_admin: true}}}, _), do: true
|
|
|
|
defp can_modify?(%{current_scope: %{user: %User{id: id}}}, %Mission{user_id: id}) when not is_nil(id), do: true
|
|
|
|
defp can_modify?(_, _), do: false
|
|
|
|
defp band_label(mhz) when is_integer(mhz) and mhz >= 1000 do
|
|
ghz = mhz / 1000
|
|
if ghz == trunc(ghz), do: "#{trunc(ghz)} GHz", else: "#{Float.round(ghz, 1)} GHz"
|
|
end
|
|
|
|
defp band_label(mhz), do: "#{mhz} MHz"
|
|
|
|
defp status_badge(:complete) do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-success badge-sm">Complete</span>|
|
|
end
|
|
|
|
defp status_badge(:computing) do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-info badge-sm">Computing</span>|
|
|
end
|
|
|
|
defp status_badge(:failed) do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-error badge-sm">Failed</span>|
|
|
end
|
|
|
|
defp status_badge(_) do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-ghost badge-sm">Pending</span>|
|
|
end
|
|
|
|
defp verdict_badge("clear") do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-success badge-xs">Clear</span>|
|
|
end
|
|
|
|
defp verdict_badge("blocked") do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-error badge-xs">Blocked</span>|
|
|
end
|
|
|
|
defp verdict_badge("marginal") do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-warning badge-xs">Marginal</span>|
|
|
end
|
|
|
|
defp verdict_badge(_), do: ""
|
|
|
|
defp progress_summary(paths) do
|
|
total = length(paths)
|
|
complete = Enum.count(paths, &(&1.status == :complete))
|
|
pending = Enum.count(paths, &(&1.status in [:pending, :computing]))
|
|
failed = Enum.count(paths, &(&1.status == :failed))
|
|
%{total: total, complete: complete, pending: pending, failed: failed}
|
|
end
|
|
|
|
defp format_distance(km) when is_number(km), do: "#{Float.round(km, 1)} km / #{Float.round(km * 0.621371, 1)} mi"
|
|
|
|
defp format_distance(_), do: "—"
|
|
|
|
defp format_db(nil), do: "—"
|
|
defp format_db(value) when is_number(value), do: "#{Float.round(value, 1)} dB"
|
|
|
|
defp rover_label(%{rover_location: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon) do
|
|
grid = Maidenhead.from_latlon(lat, lon, 6)
|
|
"#{grid} (#{Float.round(lat, 4)}, #{Float.round(lon, 4)})"
|
|
end
|
|
|
|
defp rover_label(_), do: "—"
|
|
|
|
defp station_label(%{station: %{callsign: c}}) when is_binary(c) and c != "", do: c
|
|
defp station_label(%{station: %{grid: g}}) when is_binary(g) and g != "", do: g
|
|
|
|
defp station_label(%{station: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon),
|
|
do: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}"
|
|
|
|
defp station_label(_), do: "—"
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
summary = progress_summary(assigns.paths)
|
|
assigns = assign(assigns, :summary, summary)
|
|
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
|
|
<.header>
|
|
{@mission.name}
|
|
<:subtitle>
|
|
{band_label(@mission.band_mhz)} · {length(@mission.stations)} station(s) ·
|
|
rover {@mission.rover_height_ft}ft / station {@mission.station_height_ft}ft
|
|
</:subtitle>
|
|
<:actions>
|
|
<.link navigate={~p"/rover-planning"} class="btn btn-ghost btn-sm">
|
|
<.icon name="hero-arrow-left" class="w-4 h-4" /> All missions
|
|
</.link>
|
|
<.link
|
|
:if={can_modify?(assigns, @mission)}
|
|
navigate={~p"/rover-planning/#{@mission.id}/edit"}
|
|
class="btn btn-ghost btn-sm"
|
|
>
|
|
<.icon name="hero-pencil-square" class="w-4 h-4" /> Edit
|
|
</.link>
|
|
<button
|
|
:if={can_modify?(assigns, @mission)}
|
|
type="button"
|
|
phx-click="delete"
|
|
data-confirm="Delete this mission?"
|
|
class="btn btn-ghost btn-sm text-error"
|
|
>
|
|
<.icon name="hero-trash" class="w-4 h-4" /> Delete
|
|
</button>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<div class="card bg-base-100 border border-base-300 p-4 mb-4">
|
|
<h3 class="font-semibold mb-2">Stationary stations</h3>
|
|
<ul class="text-sm space-y-1">
|
|
<li :for={station <- @mission.stations} class="flex flex-wrap gap-2">
|
|
<span class="font-mono">{station.callsign || station.grid || station.input}</span>
|
|
<span :if={station.grid} class="text-base-content/60">grid {station.grid}</span>
|
|
<span class="text-base-content/60 font-mono">
|
|
{Float.round(station.lat, 4)}, {Float.round(station.lon, 4)}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
|
|
<p :if={@mission.notes && @mission.notes != ""} class="mt-3 text-sm whitespace-pre-line">
|
|
{@mission.notes}
|
|
</p>
|
|
|
|
<p class="text-xs text-base-content/60 mt-3">
|
|
Scope: {if @mission.only_known_good,
|
|
do: "Known good locations only (status = good)",
|
|
else: "All rover locations"} · Owner: {(@mission.user && @mission.user.callsign) || "—"}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card bg-base-100 border border-base-300 p-4">
|
|
<div class="flex items-center justify-between mb-3 gap-2 flex-wrap">
|
|
<h3 class="font-semibold">Path profiles</h3>
|
|
<div class="text-xs text-base-content/70">
|
|
{@summary.complete}/{@summary.total} complete
|
|
<span :if={@summary.pending > 0}>· {@summary.pending} pending</span>
|
|
<span :if={@summary.failed > 0} class="text-error">
|
|
· {@summary.failed} failed
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div :if={@paths == []} class="text-sm text-base-content/60 py-4 text-center">
|
|
No paths yet — they'll appear here as the workers run.
|
|
</div>
|
|
|
|
<div :if={@paths != []} class="overflow-x-auto">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Rover location</th>
|
|
<th>Station</th>
|
|
<th>Status</th>
|
|
<th>Distance</th>
|
|
<th>Min clearance</th>
|
|
<th>Diffraction</th>
|
|
<th>Verdict</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={path <- @paths}>
|
|
<td class="font-mono text-xs">{rover_label(path)}</td>
|
|
<td class="font-mono text-xs">{station_label(path)}</td>
|
|
<td>{status_badge(path.status)}</td>
|
|
<td class="text-xs">
|
|
{if path.result, do: format_distance(path.result["distance_km"]), else: "—"}
|
|
</td>
|
|
<td class="text-xs">
|
|
{if path.result,
|
|
do: "#{Float.round(path.result["min_clearance_m"] || 0.0, 1)} m",
|
|
else: "—"}
|
|
</td>
|
|
<td class="text-xs">
|
|
{if path.result, do: format_db(path.result["diffraction_db"]), else: "—"}
|
|
</td>
|
|
<td>
|
|
{if path.result, do: verdict_badge(path.result["verdict"]), else: "—"}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
end
|