feat(rover-planning): /rover-planning page with path-profile worker
Adds a new globally-scoped, owner-mutable rover-mission tracker: - /rover-planning paginated table (LiveTable) with View/Edit/Delete - /rover-planning/new + /:id/edit form: name, band, antenna heights, notes, "only check against known good locations" toggle (default on), and a dynamic list of stationary stations entered as callsigns, Maidenhead grids, or lat,lon pairs (Station changeset geocodes via LocationResolver, lat/lon stays editable after add) - /rover-planning/:id show page renders the station list, scope, and a matrix of computed path profiles (distance, min clearance, diffraction, verdict) populated as the worker completes each pairing After save, RoverPlanning enqueues one RoverPathProfileWorker job per (rover-location × station) pairing. The worker mirrors PathLive's synchronous compute (ElevationClient + TerrainAnalysis at the mission's band + heights) and stores the result on the matching path row. PubSub broadcast on completion lets the show page live-refresh. Admins can edit/delete any mission; owners can edit their own.
This commit is contained in:
parent
a3fff198c9
commit
d1a5442afb
13 changed files with 1682 additions and 3 deletions
197
lib/microwaveprop/rover_planning.ex
Normal file
197
lib/microwaveprop/rover_planning.ex
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
defmodule Microwaveprop.RoverPlanning do
|
||||
@moduledoc """
|
||||
Context for the `/rover-planning` feature: planned rover missions
|
||||
with stationary endpoints and per-rover-location terrain path
|
||||
profiles. Mutations are scoped to a user (or admin); reads are
|
||||
global.
|
||||
|
||||
After a successful `create_mission/2` or
|
||||
`replace_mission_paths/1`, the context enqueues one
|
||||
`RoverPathProfileWorker` job per (rover-location, station) pair
|
||||
so the show page can render results as they land.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Ecto.Multi
|
||||
alias Microwaveprop.Accounts.User
|
||||
alias Microwaveprop.Repo
|
||||
alias Microwaveprop.Rover.Location
|
||||
alias Microwaveprop.RoverPlanning.Mission
|
||||
alias Microwaveprop.RoverPlanning.Path
|
||||
alias Microwaveprop.RoverPlanning.Station
|
||||
alias Microwaveprop.Workers.RoverPathProfileWorker
|
||||
|
||||
@spec list_missions() :: [Mission.t()]
|
||||
def list_missions do
|
||||
Mission
|
||||
|> order_by([m], desc: m.inserted_at)
|
||||
|> preload([:user, :stations])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@spec get_mission(Ecto.UUID.t()) :: Mission.t() | nil
|
||||
def get_mission(id) when is_binary(id) do
|
||||
case Ecto.UUID.cast(id) do
|
||||
{:ok, uuid} ->
|
||||
Mission
|
||||
|> Repo.get(uuid)
|
||||
|> Repo.preload([:user, stations: from(s in Station, order_by: s.position)])
|
||||
|
||||
:error ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_mission_with_paths(Ecto.UUID.t()) :: Mission.t() | nil
|
||||
def get_mission_with_paths(id) do
|
||||
case get_mission(id) do
|
||||
nil -> nil
|
||||
mission -> Repo.preload(mission, paths: [:rover_location, :station])
|
||||
end
|
||||
end
|
||||
|
||||
@spec create_mission(User.t(), map()) ::
|
||||
{:ok, Mission.t()} | {:error, Ecto.Changeset.t()}
|
||||
def create_mission(%User{} = user, attrs) do
|
||||
changeset = Mission.changeset(%Mission{user_id: user.id}, attrs)
|
||||
|
||||
case Repo.insert(changeset) do
|
||||
{:ok, mission} ->
|
||||
mission = Repo.preload(mission, [:user, :stations])
|
||||
:ok = enqueue_paths_for(mission)
|
||||
{:ok, mission}
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@spec update_mission(User.t(), Ecto.UUID.t(), map()) ::
|
||||
{:ok, Mission.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def update_mission(%User{} = user, id, attrs) do
|
||||
case fetch_owned(user, id) do
|
||||
{:ok, mission} ->
|
||||
mission
|
||||
|> Repo.preload(stations: from(s in Station, order_by: s.position))
|
||||
|> Mission.changeset(attrs)
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, updated} ->
|
||||
updated = Repo.preload(updated, [:user, :stations], force: true)
|
||||
:ok = replace_mission_paths(updated)
|
||||
{:ok, updated}
|
||||
|
||||
other ->
|
||||
other
|
||||
end
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_mission(User.t(), Ecto.UUID.t()) ::
|
||||
{:ok, Mission.t()} | {:error, :not_found}
|
||||
def delete_mission(%User{} = user, id) do
|
||||
case fetch_owned(user, id) do
|
||||
{:ok, mission} -> Repo.delete(mission)
|
||||
{:error, :not_found} -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_paths(Mission.t()) :: [Path.t()]
|
||||
def list_paths(%Mission{id: mission_id}) do
|
||||
Repo.all(
|
||||
from(p in Path,
|
||||
where: p.mission_id == ^mission_id,
|
||||
preload: [:rover_location, :station],
|
||||
order_by: [asc: p.station_id, asc: p.rover_location_id]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Wipes the existing path matrix for a mission and re-enqueues all
|
||||
pairings. Called after a mission edit when the station list or
|
||||
`only_known_good` toggle could have shifted the matrix.
|
||||
"""
|
||||
@spec replace_mission_paths(Mission.t()) :: :ok
|
||||
def replace_mission_paths(%Mission{} = mission) do
|
||||
Repo.delete_all(from(p in Path, where: p.mission_id == ^mission.id))
|
||||
enqueue_paths_for(mission)
|
||||
end
|
||||
|
||||
defp enqueue_paths_for(%Mission{} = mission) do
|
||||
rovers = candidate_rover_locations(mission)
|
||||
stations = mission.stations || []
|
||||
|
||||
pairs =
|
||||
for rover <- rovers, station <- stations do
|
||||
%{rover: rover, station: station}
|
||||
end
|
||||
|
||||
case pairs do
|
||||
[] -> :ok
|
||||
_ -> insert_pending_paths_and_jobs(mission, pairs)
|
||||
end
|
||||
end
|
||||
|
||||
defp candidate_rover_locations(%Mission{only_known_good: true}) do
|
||||
Repo.all(from(l in Location, where: l.status == :ideal))
|
||||
end
|
||||
|
||||
defp candidate_rover_locations(%Mission{only_known_good: false}) do
|
||||
Repo.all(Location)
|
||||
end
|
||||
|
||||
defp insert_pending_paths_and_jobs(mission, pairs) do
|
||||
{:ok, _} =
|
||||
pairs
|
||||
|> Enum.reduce(Multi.new(), fn %{rover: r, station: s}, multi ->
|
||||
attrs = %{
|
||||
mission_id: mission.id,
|
||||
rover_location_id: r.id,
|
||||
station_id: s.id,
|
||||
status: :pending
|
||||
}
|
||||
|
||||
Multi.insert(multi, {:path, r.id, s.id}, Path.changeset(%Path{}, attrs),
|
||||
on_conflict: :nothing,
|
||||
conflict_target: [:mission_id, :rover_location_id, :station_id]
|
||||
)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|
||||
enqueue_jobs(mission, pairs)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp enqueue_jobs(mission, pairs) do
|
||||
for %{rover: r, station: s} <- pairs do
|
||||
%{
|
||||
"mission_id" => mission.id,
|
||||
"rover_location_id" => r.id,
|
||||
"station_id" => s.id
|
||||
}
|
||||
|> RoverPathProfileWorker.new()
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp fetch_owned(%User{is_admin: true}, id) do
|
||||
case get_mission(id) do
|
||||
nil -> {:error, :not_found}
|
||||
mission -> {:ok, mission}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_owned(%User{id: user_id}, id) do
|
||||
case get_mission(id) do
|
||||
%Mission{user_id: ^user_id} = mission -> {:ok, mission}
|
||||
_ -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
71
lib/microwaveprop/rover_planning/mission.ex
Normal file
71
lib/microwaveprop/rover_planning/mission.ex
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
defmodule Microwaveprop.RoverPlanning.Mission do
|
||||
@moduledoc """
|
||||
A planned rover mission. Owned by a user but globally visible. Holds
|
||||
the band + height parameters that drive path-profile computation, the
|
||||
list of stationary endpoints (`stations`), and the matrix of computed
|
||||
paths from each rover-location to each station (`paths`).
|
||||
|
||||
`only_known_good` toggles whether the rover endpoint set is the
|
||||
`:ideal` rover-locations (default) or every rover-location.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Microwaveprop.Accounts.User
|
||||
alias Microwaveprop.RoverPlanning.Path
|
||||
alias Microwaveprop.RoverPlanning.Station
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "rover_missions" do
|
||||
field :name, :string
|
||||
field :band_mhz, :integer, default: 10_000
|
||||
field :only_known_good, :boolean, default: true
|
||||
field :rover_height_ft, :float, default: 8.0
|
||||
field :station_height_ft, :float, default: 30.0
|
||||
field :notes, :string
|
||||
|
||||
belongs_to :user, User
|
||||
has_many :stations, Station, foreign_key: :mission_id, on_replace: :delete
|
||||
has_many :paths, Path, foreign_key: :mission_id
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
@castable_fields [
|
||||
:name,
|
||||
:band_mhz,
|
||||
:only_known_good,
|
||||
:rover_height_ft,
|
||||
:station_height_ft,
|
||||
:notes
|
||||
]
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(mission, attrs) do
|
||||
mission
|
||||
|> cast(attrs, @castable_fields)
|
||||
|> cast_assoc(:stations, with: &Station.changeset/2, required: true)
|
||||
|> validate_required([:name, :band_mhz])
|
||||
|> validate_length(:name, min: 1, max: 100)
|
||||
|> validate_number(:band_mhz, greater_than: 0)
|
||||
|> validate_number(:rover_height_ft, greater_than_or_equal_to: 0.0)
|
||||
|> validate_number(:station_height_ft, greater_than_or_equal_to: 0.0)
|
||||
|> validate_length(:notes, max: 4000)
|
||||
|> validate_at_least_one_station()
|
||||
end
|
||||
|
||||
defp validate_at_least_one_station(changeset) do
|
||||
case fetch_field(changeset, :stations) do
|
||||
{_source, list} when is_list(list) and list != [] ->
|
||||
kept = Enum.reject(list, fn s -> match?(%Ecto.Changeset{action: :replace}, s) end)
|
||||
if kept == [], do: add_error(changeset, :stations, "must include at least one station"), else: changeset
|
||||
|
||||
_ ->
|
||||
add_error(changeset, :stations, "must include at least one station")
|
||||
end
|
||||
end
|
||||
end
|
||||
56
lib/microwaveprop/rover_planning/path.ex
Normal file
56
lib/microwaveprop/rover_planning/path.ex
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
defmodule Microwaveprop.RoverPlanning.Path do
|
||||
@moduledoc """
|
||||
A computed (or pending) path-profile result for one rover-location
|
||||
→ station pairing inside a mission. `result` holds the same shape
|
||||
produced by `MicrowavepropWeb.PathLive`'s synchronous compute, so
|
||||
the show page can render it identically.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Microwaveprop.Rover.Location
|
||||
alias Microwaveprop.RoverPlanning.Mission
|
||||
alias Microwaveprop.RoverPlanning.Station
|
||||
|
||||
@statuses [:pending, :computing, :complete, :failed]
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "rover_mission_paths" do
|
||||
field :status, Ecto.Enum, values: @statuses, default: :pending
|
||||
field :result, :map
|
||||
field :error, :string
|
||||
field :computed_at, :utc_datetime
|
||||
|
||||
belongs_to :mission, Mission
|
||||
belongs_to :rover_location, Location
|
||||
belongs_to :station, Station
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
@spec statuses() :: [atom()]
|
||||
def statuses, do: @statuses
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(path, attrs) do
|
||||
path
|
||||
|> cast(attrs, [
|
||||
:status,
|
||||
:result,
|
||||
:error,
|
||||
:computed_at,
|
||||
:mission_id,
|
||||
:rover_location_id,
|
||||
:station_id
|
||||
])
|
||||
|> validate_required([:mission_id, :rover_location_id, :station_id, :status])
|
||||
|> validate_inclusion(:status, @statuses)
|
||||
|> unique_constraint([:mission_id, :rover_location_id, :station_id],
|
||||
name: :rover_mission_paths_unique
|
||||
)
|
||||
end
|
||||
end
|
||||
86
lib/microwaveprop/rover_planning/station.ex
Normal file
86
lib/microwaveprop/rover_planning/station.ex
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
defmodule Microwaveprop.RoverPlanning.Station do
|
||||
@moduledoc """
|
||||
A stationary endpoint for a rover mission. The user provides one of:
|
||||
callsign, Maidenhead grid, or `lat, lon`; the changeset normalizes
|
||||
the input into all three when possible (callsigns geocode via
|
||||
`MicrowavepropWeb.LocationResolver`).
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Microwaveprop.RoverPlanning.Mission
|
||||
alias MicrowavepropWeb.LocationResolver
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "rover_mission_stations" do
|
||||
field :callsign, :string
|
||||
field :grid, :string
|
||||
field :input, :string
|
||||
field :lat, :float
|
||||
field :lon, :float
|
||||
field :position, :integer, default: 0
|
||||
|
||||
belongs_to :mission, Mission
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(station, attrs) do
|
||||
station
|
||||
|> cast(attrs, [:callsign, :grid, :input, :lat, :lon, :position])
|
||||
|> validate_required([:input])
|
||||
|> resolve_input()
|
||||
|> validate_required([:lat, :lon])
|
||||
|> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|
||||
|> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|
||||
end
|
||||
|
||||
# When `input` looks like a callsign or grid, geocode it and populate
|
||||
# the matching field. Skips resolution when the user already provided
|
||||
# explicit lat/lon — they may want a free-form coordinate that doesn't
|
||||
# round-trip through callsign lookup.
|
||||
defp resolve_input(changeset) do
|
||||
cond do
|
||||
not changeset.valid? ->
|
||||
changeset
|
||||
|
||||
get_field(changeset, :lat) && get_field(changeset, :lon) ->
|
||||
changeset
|
||||
|
||||
input = get_field(changeset, :input) ->
|
||||
apply_resolution(changeset, LocationResolver.resolve(String.trim(input)))
|
||||
|
||||
true ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_resolution(changeset, {:ok, %{kind: :callsign} = res}) do
|
||||
changeset
|
||||
|> put_change(:callsign, res.label)
|
||||
|> put_change(:grid, Map.get(res, :grid))
|
||||
|> put_change(:lat, res.lat)
|
||||
|> put_change(:lon, res.lon)
|
||||
end
|
||||
|
||||
defp apply_resolution(changeset, {:ok, %{kind: :grid} = res}) do
|
||||
changeset
|
||||
|> put_change(:grid, res.label)
|
||||
|> put_change(:lat, res.lat)
|
||||
|> put_change(:lon, res.lon)
|
||||
end
|
||||
|
||||
defp apply_resolution(changeset, {:ok, %{kind: :coordinates} = res}) do
|
||||
changeset
|
||||
|> put_change(:lat, res.lat)
|
||||
|> put_change(:lon, res.lon)
|
||||
end
|
||||
|
||||
defp apply_resolution(changeset, {:error, msg}), do: add_error(changeset, :input, msg)
|
||||
defp apply_resolution(changeset, :empty), do: add_error(changeset, :input, "is required")
|
||||
end
|
||||
177
lib/microwaveprop/workers/rover_path_profile_worker.ex
Normal file
177
lib/microwaveprop/workers/rover_path_profile_worker.ex
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||
@moduledoc """
|
||||
Computes a terrain path profile for one rover-location → station
|
||||
pairing inside a `RoverPlanning.Mission`. Mirrors the synchronous
|
||||
`MicrowavepropWeb.PathLive` compute pipeline (elevation profile +
|
||||
ITU-R P.526 analysis at the mission's band + heights) and stores
|
||||
the result as JSON on the matching `RoverPlanning.Path`.
|
||||
|
||||
The HRRR-along-path step from `/path` is intentionally skipped here:
|
||||
it depends on real-time profile grids that aren't worth pinning to a
|
||||
stored mission result. The rendered show page can re-fetch live
|
||||
weather on demand if needed.
|
||||
"""
|
||||
use Oban.Worker,
|
||||
queue: :terrain,
|
||||
max_attempts: 5,
|
||||
unique: [
|
||||
period: 300,
|
||||
keys: [:mission_id, :rover_location_id, :station_id],
|
||||
states: [:available, :scheduled, :executing, :retryable]
|
||||
]
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Microwaveprop.Geo
|
||||
alias Microwaveprop.Repo
|
||||
alias Microwaveprop.RoverPlanning.Path
|
||||
alias Microwaveprop.Terrain.ElevationClient
|
||||
alias Microwaveprop.Terrain.TerrainAnalysis
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def backoff(%Oban.Job{attempt: attempt}) do
|
||||
min(60 * Integer.pow(2, attempt - 1), 3600)
|
||||
end
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{
|
||||
args: %{"mission_id" => mission_id, "rover_location_id" => rover_location_id, "station_id" => station_id}
|
||||
}) do
|
||||
case load_path(mission_id, rover_location_id, station_id) do
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
%Path{status: :complete} ->
|
||||
:ok
|
||||
|
||||
%Path{} = path ->
|
||||
run(path)
|
||||
end
|
||||
end
|
||||
|
||||
defp load_path(mission_id, rover_location_id, station_id) do
|
||||
Path
|
||||
|> where([p], p.mission_id == ^mission_id)
|
||||
|> where([p], p.rover_location_id == ^rover_location_id)
|
||||
|> where([p], p.station_id == ^station_id)
|
||||
|> preload([:rover_location, :station, :mission])
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp run(%Path{rover_location: rover, station: station, mission: mission} = path) do
|
||||
mark_status(path, :computing)
|
||||
|
||||
rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048
|
||||
station_height_m = (mission.station_height_ft || 30.0) * 0.3048
|
||||
freq_ghz = mission.band_mhz / 1000
|
||||
dist_km = Geo.haversine_km(rover.lat, rover.lon, station.lat, station.lon)
|
||||
bearing = Geo.bearing_deg(rover.lat, rover.lon, station.lat, station.lon)
|
||||
|
||||
case ElevationClient.fetch_elevation_profile(
|
||||
rover.lat,
|
||||
rover.lon,
|
||||
station.lat,
|
||||
station.lon,
|
||||
64,
|
||||
download: true
|
||||
) do
|
||||
{:ok, profile} ->
|
||||
analysis =
|
||||
TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
|
||||
ant_ht_a: rover_height_m,
|
||||
ant_ht_b: station_height_m
|
||||
)
|
||||
|
||||
store_complete(path, profile, analysis, dist_km, bearing)
|
||||
|
||||
{:error, reason} ->
|
||||
store_failed(path, "elevation profile failed: #{inspect(reason)}")
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("RoverPathProfileWorker crashed for path=#{path.id}: #{Exception.message(e)}")
|
||||
store_failed(path, "exception: #{Exception.message(e)}")
|
||||
reraise e, __STACKTRACE__
|
||||
end
|
||||
|
||||
defp store_complete(path, profile, analysis, dist_km, bearing) do
|
||||
points =
|
||||
Enum.map(profile, fn p ->
|
||||
%{
|
||||
"lat" => p.lat,
|
||||
"lon" => p.lon,
|
||||
"d" => p.d,
|
||||
"elev" => p.elev,
|
||||
"dist_km" => p.dist_km
|
||||
}
|
||||
end)
|
||||
|
||||
result = %{
|
||||
"distance_km" => dist_km,
|
||||
"bearing_deg" => bearing,
|
||||
"max_elevation_m" => analysis.max_elevation_m,
|
||||
"min_clearance_m" => analysis.min_clearance_m,
|
||||
"diffraction_db" => analysis.diffraction_db,
|
||||
"fresnel_hit_count" => analysis.fresnel_hit_count,
|
||||
"obstructed_count" => analysis.obstructed_count,
|
||||
"verdict" => to_string(analysis.verdict),
|
||||
"sample_count" => length(profile),
|
||||
"path_points" => points
|
||||
}
|
||||
|
||||
path
|
||||
|> Path.changeset(%{
|
||||
status: :complete,
|
||||
result: result,
|
||||
error: nil,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
})
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, updated} ->
|
||||
broadcast(updated)
|
||||
:ok
|
||||
|
||||
{:error, cs} ->
|
||||
{:error, "store update failed: #{inspect(cs.errors)}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp store_failed(path, message) do
|
||||
path
|
||||
|> Path.changeset(%{
|
||||
status: :failed,
|
||||
error: message,
|
||||
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
})
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, updated} ->
|
||||
broadcast(updated)
|
||||
{:error, message}
|
||||
|
||||
{:error, cs} ->
|
||||
{:error, "store update failed: #{inspect(cs.errors)}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp mark_status(path, status) do
|
||||
path
|
||||
|> Path.changeset(%{status: status})
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, _} -> :ok
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp broadcast(%Path{mission_id: mission_id} = path) do
|
||||
Phoenix.PubSub.broadcast(
|
||||
Microwaveprop.PubSub,
|
||||
"rover_planning:#{mission_id}",
|
||||
{:rover_path_updated, path.id}
|
||||
)
|
||||
end
|
||||
end
|
||||
203
lib/microwaveprop_web/live/rover_planning_live.ex
Normal file
203
lib/microwaveprop_web/live/rover_planning_live.ex
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
defmodule MicrowavepropWeb.RoverPlanningLive do
|
||||
@moduledoc """
|
||||
`/rover-planning` paginated table of planned rover missions.
|
||||
Globally visible, owner-mutable; admins can edit/delete any mission.
|
||||
"""
|
||||
use MicrowavepropWeb, :live_view
|
||||
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.RoverPlanning.Mission
|
||||
|
||||
alias Microwaveprop.Accounts.User
|
||||
alias Microwaveprop.RoverPlanning
|
||||
|
||||
def table_options do
|
||||
%{sorting: %{default_sort: [inserted_at: :desc]}}
|
||||
end
|
||||
|
||||
def fields do
|
||||
[
|
||||
id: %{label: "ID", hidden: true},
|
||||
user_id: %{label: "Owner", hidden: true},
|
||||
name: %{label: "Mission", sortable: true, searchable: true, renderer: &name_cell/2},
|
||||
band_mhz: %{label: "Band", sortable: true, renderer: &band_cell/1},
|
||||
only_known_good: %{label: "Scope", sortable: true, renderer: &scope_cell/1},
|
||||
inserted_at: %{label: "Created", sortable: true, renderer: &format_ts/1}
|
||||
]
|
||||
end
|
||||
|
||||
def filters, do: []
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
assign(socket,
|
||||
page_title: "Rover Planning"
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
case current_user(socket) do
|
||||
%User{} = user ->
|
||||
case RoverPlanning.delete_mission(user, id) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Mission deleted.")
|
||||
|> reload()}
|
||||
|
||||
{: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 reload(socket) do
|
||||
options = socket.assigns.options
|
||||
data_provider = socket.assigns[:data_provider]
|
||||
table_options = get_merged_table_options()
|
||||
|
||||
{resources, updated_options} = fetch_resources(options, data_provider)
|
||||
|
||||
socket
|
||||
|> assign_to_socket(resources, table_options)
|
||||
|> assign(:options, updated_options)
|
||||
end
|
||||
|
||||
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns)
|
||||
defp current_user(assigns) when is_map(assigns), do: scope_user(assigns)
|
||||
|
||||
defp scope_user(assigns) do
|
||||
case assigns[:current_scope] do
|
||||
%{user: %User{} = user} -> user
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp can_modify?(scope, %{user_id: user_id}) do
|
||||
case scope do
|
||||
%{user: %User{is_admin: true}} -> true
|
||||
%{user: %User{id: ^user_id}} when not is_nil(user_id) -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp can_modify?(_, _), do: false
|
||||
|
||||
defp actions_for(scope) do
|
||||
[
|
||||
buttons: fn %{record: record} ->
|
||||
row_actions(record, scope)
|
||||
end
|
||||
]
|
||||
end
|
||||
|
||||
defp row_actions(record, scope) do
|
||||
assigns = %{record: record, can_modify?: can_modify?(scope, record)}
|
||||
|
||||
~H"""
|
||||
<div class="flex gap-2">
|
||||
<.link
|
||||
navigate={~p"/rover-planning/#{@record.id}"}
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
View
|
||||
</.link>
|
||||
<.link
|
||||
:if={@can_modify?}
|
||||
navigate={~p"/rover-planning/#{@record.id}/edit"}
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
Edit
|
||||
</.link>
|
||||
<button
|
||||
:if={@can_modify?}
|
||||
type="button"
|
||||
phx-click="delete"
|
||||
phx-value-id={@record.id}
|
||||
data-confirm="Delete this mission?"
|
||||
class="btn btn-xs btn-ghost text-error"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp name_cell(value, %{id: id}) do
|
||||
assigns = %{id: id, name: value || "(unnamed)"}
|
||||
|
||||
~H"""
|
||||
<.link navigate={~p"/rover-planning/#{@id}"} class="font-semibold hover:underline">
|
||||
{@name}
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
defp band_cell(nil), do: "—"
|
||||
|
||||
defp band_cell(mhz) when is_integer(mhz) do
|
||||
if mhz >= 1000 do
|
||||
ghz = mhz / 1000
|
||||
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: Float.to_string(Float.round(ghz, 1))
|
||||
"#{label} GHz"
|
||||
else
|
||||
"#{mhz} MHz"
|
||||
end
|
||||
end
|
||||
|
||||
defp scope_cell(true) do
|
||||
assigns = %{}
|
||||
~H|<span class="badge badge-success badge-sm">Known good only</span>|
|
||||
end
|
||||
|
||||
defp scope_cell(false) do
|
||||
assigns = %{}
|
||||
~H|<span class="badge badge-info badge-sm">All locations</span>|
|
||||
end
|
||||
|
||||
defp scope_cell(_), do: ""
|
||||
|
||||
defp format_ts(nil), do: ""
|
||||
defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
|
||||
defp format_ts(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
|
||||
defp format_ts(other), do: to_string(other)
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
|
||||
<.header>
|
||||
Rover Planning
|
||||
<:subtitle>
|
||||
Plan rover missions: enter your stationary stations, pick a band, and
|
||||
we compute terrain path profiles from every known-good rover location.
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.link
|
||||
:if={current_user(assigns)}
|
||||
navigate={~p"/rover-planning/new"}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-5 h-5" /> New mission
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<p :if={is_nil(current_user(assigns))} class="alert alert-info text-sm mb-4">
|
||||
<a href={~p"/users/log-in"} class="link link-primary">Sign in</a> to plan a mission.
|
||||
</p>
|
||||
|
||||
<.live_table
|
||||
fields={fields()}
|
||||
filters={filters()}
|
||||
options={@options}
|
||||
streams={@streams}
|
||||
actions={actions_for(@current_scope)}
|
||||
/>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
299
lib/microwaveprop_web/live/rover_planning_live/form.ex
Normal file
299
lib/microwaveprop_web/live/rover_planning_live/form.ex
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
defmodule MicrowavepropWeb.RoverPlanningLive.Form do
|
||||
@moduledoc "New / edit form for `/rover-planning` missions."
|
||||
use MicrowavepropWeb, :live_view
|
||||
|
||||
alias Microwaveprop.Accounts.User
|
||||
alias Microwaveprop.RoverPlanning
|
||||
alias Microwaveprop.RoverPlanning.Mission
|
||||
alias Microwaveprop.RoverPlanning.Station
|
||||
|
||||
@band_options [
|
||||
{"902 MHz", 902},
|
||||
{"1.3 GHz", 1296},
|
||||
{"2.3 GHz", 2304},
|
||||
{"3.4 GHz", 3400},
|
||||
{"5.7 GHz", 5760},
|
||||
{"10 GHz", 10_000},
|
||||
{"24 GHz", 24_000},
|
||||
{"47 GHz", 47_000}
|
||||
]
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
case current_user(socket) do
|
||||
nil ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Sign in to plan a mission.")
|
||||
|> push_navigate(to: ~p"/rover-planning")}
|
||||
|
||||
%User{} = user ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(
|
||||
page_title: "Rover Mission",
|
||||
band_options: @band_options,
|
||||
current_user: user
|
||||
)
|
||||
|> apply_action(socket.assigns.live_action, params)}
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
mission = %Mission{stations: [%Station{position: 0}]}
|
||||
cs = Mission.changeset(mission, %{})
|
||||
|
||||
assign(socket,
|
||||
mission: mission,
|
||||
form: to_form(cs),
|
||||
mode: :new
|
||||
)
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
user = socket.assigns.current_user
|
||||
|
||||
case RoverPlanning.get_mission(id) do
|
||||
nil ->
|
||||
socket
|
||||
|> put_flash(:error, "Mission not found.")
|
||||
|> push_navigate(to: ~p"/rover-planning")
|
||||
|
||||
mission ->
|
||||
if can_modify?(user, mission) do
|
||||
cs = Mission.changeset(mission, %{})
|
||||
|
||||
assign(socket,
|
||||
mission: mission,
|
||||
form: to_form(cs),
|
||||
mode: :edit
|
||||
)
|
||||
else
|
||||
socket
|
||||
|> put_flash(:error, "You can only edit your own missions.")
|
||||
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"mission" => params}, socket) do
|
||||
cs =
|
||||
socket.assigns.mission
|
||||
|> Mission.changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, form: to_form(cs))}
|
||||
end
|
||||
|
||||
def handle_event("add_station", _params, socket) do
|
||||
params = current_params(socket)
|
||||
stations = Map.get(params, "stations", %{})
|
||||
next_index = next_station_index(stations)
|
||||
|
||||
stations =
|
||||
Map.put(stations, Integer.to_string(next_index), %{
|
||||
"input" => "",
|
||||
"position" => Integer.to_string(next_index)
|
||||
})
|
||||
|
||||
cs =
|
||||
socket.assigns.mission
|
||||
|> Mission.changeset(Map.put(params, "stations", stations))
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, form: to_form(cs))}
|
||||
end
|
||||
|
||||
def handle_event("remove_station", %{"index" => index}, socket) do
|
||||
params = current_params(socket)
|
||||
stations = Map.get(params, "stations", %{})
|
||||
stations = Map.delete(stations, index)
|
||||
|
||||
cs =
|
||||
socket.assigns.mission
|
||||
|> Mission.changeset(Map.put(params, "stations", stations))
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, form: to_form(cs))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"mission" => params}, socket) do
|
||||
user = socket.assigns.current_user
|
||||
|
||||
result =
|
||||
case socket.assigns.mode do
|
||||
:new -> RoverPlanning.create_mission(user, params)
|
||||
:edit -> RoverPlanning.update_mission(user, socket.assigns.mission.id, params)
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, mission} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Mission saved — computing path profiles.")
|
||||
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:noreply, put_flash(socket, :error, "Mission not found.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = cs} ->
|
||||
{:noreply, assign(socket, form: to_form(cs))}
|
||||
end
|
||||
end
|
||||
|
||||
defp current_params(socket) do
|
||||
case socket.assigns.form.source do
|
||||
%Ecto.Changeset{params: params} when is_map(params) -> params
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
defp next_station_index(stations) when map_size(stations) == 0, do: 0
|
||||
|
||||
defp next_station_index(stations) do
|
||||
stations
|
||||
|> Map.keys()
|
||||
|> Enum.map(fn k ->
|
||||
case Integer.parse(to_string(k)) do
|
||||
{n, _} -> n
|
||||
:error -> -1
|
||||
end
|
||||
end)
|
||||
|> Enum.max()
|
||||
|> Kernel.+(1)
|
||||
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?(%User{is_admin: true}, _), do: true
|
||||
defp can_modify?(%User{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true
|
||||
defp can_modify?(_, _), do: false
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-3xl">
|
||||
<.header>
|
||||
{if @mode == :edit, do: "Edit mission", else: "New rover mission"}
|
||||
<:subtitle>
|
||||
Stationary stations can be callsigns, Maidenhead grids, or <code>lat, lon</code> pairs.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
id="mission-form"
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
class="space-y-4"
|
||||
>
|
||||
<.input field={@form[:name]} type="text" label="Mission name" placeholder="DFW August rove" />
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<.input
|
||||
field={@form[:band_mhz]}
|
||||
type="select"
|
||||
label="Band"
|
||||
options={@band_options}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:rover_height_ft]}
|
||||
type="number"
|
||||
step="any"
|
||||
label="Rover antenna (ft)"
|
||||
/>
|
||||
<.input
|
||||
field={@form[:station_height_ft]}
|
||||
type="number"
|
||||
step="any"
|
||||
label="Station antenna (ft)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="mission[only_known_good]"
|
||||
value="true"
|
||||
checked={Phoenix.HTML.Form.normalize_value("checkbox", @form[:only_known_good].value)}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<input type="hidden" name="mission[only_known_good]" value="false" />
|
||||
<span>
|
||||
Only check against known good locations
|
||||
<span class="text-xs opacity-70 block">
|
||||
When checked, paths are computed only against rover-locations marked Ideal.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold">Stationary stations</h3>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="add_station"
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-4 h-4" /> Add station
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<.inputs_for :let={s} field={@form[:stations]}>
|
||||
<div class="card bg-base-200 p-3 mb-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<.input
|
||||
field={s[:input]}
|
||||
type="text"
|
||||
label="Callsign / grid / lat,lon"
|
||||
placeholder="W5LUA or EM13qc or 32.91, -97.06"
|
||||
/>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<.input field={s[:lat]} type="number" step="any" label="Lat" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<.input field={s[:lon]} type="number" step="any" label="Lon" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_station"
|
||||
phx-value-index={s.index}
|
||||
class="btn btn-ghost btn-sm text-error mb-2"
|
||||
title="Remove station"
|
||||
>
|
||||
<.icon name="hero-trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p :if={s[:callsign].value} class="text-xs opacity-70 mt-1 px-1">
|
||||
Resolved callsign: {s[:callsign].value}
|
||||
<span :if={s[:grid].value}>· grid {s[:grid].value}</span>
|
||||
</p>
|
||||
</div>
|
||||
</.inputs_for>
|
||||
|
||||
<p :if={@form[:stations].errors != []} class="text-error text-sm">
|
||||
{Phoenix.HTML.html_escape(Enum.map_join(@form[:stations].errors, ", ", &elem(&1, 0)))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.input field={@form[:notes]} type="textarea" label="Notes" rows="3" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{if @mode == :edit, do: "Save changes", else: "Create mission"}
|
||||
</button>
|
||||
<.link navigate={~p"/rover-planning"} class="btn btn-ghost">Cancel</.link>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
264
lib/microwaveprop_web/live/rover_planning_live/show.ex
Normal file
264
lib/microwaveprop_web/live/rover_planning_live/show.ex
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
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 = ideal)",
|
||||
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
|
||||
|
|
@ -198,6 +198,10 @@ defmodule MicrowavepropWeb.Router do
|
|||
live "/rover", RoverLive
|
||||
live "/rover-locations", RoverLocationsLive
|
||||
live "/rover-locations/:id", RoverLocationsLive.Show
|
||||
live "/rover-planning", RoverPlanningLive
|
||||
live "/rover-planning/new", RoverPlanningLive.Form, :new
|
||||
live "/rover-planning/:id", RoverPlanningLive.Show
|
||||
live "/rover-planning/:id/edit", RoverPlanningLive.Form, :edit
|
||||
live "/algo", AlgoLive
|
||||
live "/about", AboutLive
|
||||
live "/privacy", PrivacyLive
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
defmodule Microwaveprop.Repo.Migrations.CreateRoverPlanning do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:rover_missions, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true, null: false
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all), null: false
|
||||
add :name, :string, null: false
|
||||
add :band_mhz, :integer, null: false, default: 10_000
|
||||
add :only_known_good, :boolean, null: false, default: true
|
||||
add :rover_height_ft, :float, null: false, default: 8.0
|
||||
add :station_height_ft, :float, null: false, default: 30.0
|
||||
add :notes, :text
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:rover_missions, [:user_id])
|
||||
create index(:rover_missions, [:inserted_at])
|
||||
|
||||
create table(:rover_mission_stations, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true, null: false
|
||||
|
||||
add :mission_id, references(:rover_missions, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :callsign, :string
|
||||
add :grid, :string
|
||||
add :input, :string, null: false
|
||||
add :lat, :float, null: false
|
||||
add :lon, :float, null: false
|
||||
add :position, :integer, null: false, default: 0
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:rover_mission_stations, [:mission_id])
|
||||
|
||||
create table(:rover_mission_paths, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true, null: false
|
||||
|
||||
add :mission_id, references(:rover_missions, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :rover_location_id,
|
||||
references(:rover_locations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :station_id,
|
||||
references(:rover_mission_stations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :status, :string, null: false, default: "pending"
|
||||
add :result, :map
|
||||
add :error, :text
|
||||
add :computed_at, :utc_datetime
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:rover_mission_paths, [:mission_id])
|
||||
create index(:rover_mission_paths, [:status])
|
||||
|
||||
create unique_index(:rover_mission_paths, [:mission_id, :rover_location_id, :station_id],
|
||||
name: :rover_mission_paths_unique
|
||||
)
|
||||
end
|
||||
end
|
||||
144
test/microwaveprop/rover_planning_test.exs
Normal file
144
test/microwaveprop/rover_planning_test.exs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
defmodule Microwaveprop.RoverPlanningTest do
|
||||
use Microwaveprop.DataCase, async: true
|
||||
|
||||
alias Microwaveprop.AccountsFixtures
|
||||
alias Microwaveprop.Repo
|
||||
alias Microwaveprop.Rover
|
||||
alias Microwaveprop.RoverPlanning
|
||||
alias Microwaveprop.RoverPlanning.Mission
|
||||
alias Microwaveprop.Terrain.ElevationClient
|
||||
|
||||
setup do
|
||||
# Oban runs inline in test, so RoverPathProfileWorker fires immediately
|
||||
# when the context enqueues jobs. Stub elevation lookups so the worker
|
||||
# can complete without hitting the real API.
|
||||
Req.Test.stub(ElevationClient, fn conn ->
|
||||
params = Plug.Conn.fetch_query_params(conn).query_params
|
||||
lat_count = params["latitude"] |> String.split(",") |> length()
|
||||
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp create_rover_location(attrs) do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
|
||||
{:ok, loc} =
|
||||
Rover.create_location(
|
||||
user,
|
||||
Map.merge(%{lat: 32.5, lon: -97.5, status: :ideal}, attrs)
|
||||
)
|
||||
|
||||
loc
|
||||
end
|
||||
|
||||
defp valid_attrs(extra \\ %{}) do
|
||||
Map.merge(
|
||||
%{
|
||||
"name" => "Test mission",
|
||||
"band_mhz" => 10_000,
|
||||
"only_known_good" => true,
|
||||
"rover_height_ft" => 8.0,
|
||||
"station_height_ft" => 30.0,
|
||||
"stations" => %{
|
||||
"0" => %{"input" => "EM12kp", "position" => 0}
|
||||
}
|
||||
},
|
||||
extra
|
||||
)
|
||||
end
|
||||
|
||||
describe "create_mission/2" do
|
||||
test "inserts a mission with stations and enqueues a path per (rover × station) pair" do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
_ideal_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
|
||||
_off_limits = create_rover_location(%{lat: 33.0, lon: -98.0, status: :off_limits})
|
||||
|
||||
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
|
||||
assert mission.user_id == user.id
|
||||
assert [station] = mission.stations
|
||||
assert station.grid == "EM12KP"
|
||||
assert is_float(station.lat)
|
||||
assert is_float(station.lon)
|
||||
|
||||
paths = RoverPlanning.list_paths(mission)
|
||||
# only_known_good=true → only the :ideal location pairs with the station.
|
||||
# Oban runs inline in test so the worker has already completed.
|
||||
assert length(paths) == 1
|
||||
assert hd(paths).status in [:complete, :failed]
|
||||
end
|
||||
|
||||
test "with only_known_good=false, all rover-locations get a path entry" do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
_ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
|
||||
_off = create_rover_location(%{lat: 33.0, lon: -98.0, status: :off_limits})
|
||||
|
||||
attrs = valid_attrs(%{"only_known_good" => false})
|
||||
assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
|
||||
assert length(RoverPlanning.list_paths(mission)) == 2
|
||||
end
|
||||
|
||||
test "missing stations returns a changeset error" do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
attrs = valid_attrs() |> Map.delete("stations")
|
||||
|
||||
assert {:error, %Ecto.Changeset{} = cs} = RoverPlanning.create_mission(user, attrs)
|
||||
refute cs.valid?
|
||||
assert {_, _} = cs.errors[:stations] || hd(cs.errors)
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_mission/3" do
|
||||
test "owner can update a mission and paths get rebuilt" do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
|
||||
{:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
|
||||
|
||||
[original] = RoverPlanning.list_paths(mission)
|
||||
|
||||
attrs = valid_attrs(%{"name" => "Updated"})
|
||||
assert {:ok, updated} = RoverPlanning.update_mission(user, mission.id, attrs)
|
||||
assert updated.name == "Updated"
|
||||
|
||||
# Old path was deleted by replace, fresh row inserted with new id.
|
||||
[fresh] = RoverPlanning.list_paths(updated)
|
||||
refute fresh.id == original.id
|
||||
end
|
||||
|
||||
test "non-owner update is denied" do
|
||||
owner = AccountsFixtures.user_fixture()
|
||||
other = AccountsFixtures.user_fixture()
|
||||
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
|
||||
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
|
||||
|
||||
assert {:error, :not_found} =
|
||||
RoverPlanning.update_mission(other, mission.id, valid_attrs())
|
||||
end
|
||||
|
||||
test "admin can update any mission" do
|
||||
owner = AccountsFixtures.user_fixture()
|
||||
admin = AccountsFixtures.user_fixture()
|
||||
{:ok, admin} = Microwaveprop.Accounts.admin_update_user(admin, %{is_admin: true})
|
||||
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
|
||||
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
|
||||
|
||||
assert {:ok, updated} =
|
||||
RoverPlanning.update_mission(admin, mission.id, valid_attrs(%{"name" => "By admin"}))
|
||||
|
||||
assert updated.name == "By admin"
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_mission/2" do
|
||||
test "owner can delete; non-owner cannot" do
|
||||
owner = AccountsFixtures.user_fixture()
|
||||
other = AccountsFixtures.user_fixture()
|
||||
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
|
||||
|
||||
assert {:error, :not_found} = RoverPlanning.delete_mission(other, mission.id)
|
||||
assert {:ok, _} = RoverPlanning.delete_mission(owner, mission.id)
|
||||
refute Repo.get(Mission, mission.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -130,9 +130,7 @@ defmodule MicrowavepropWeb.ContactMapLiveTest do
|
|||
|> render_submit()
|
||||
|
||||
html =
|
||||
render_click(
|
||||
element(lv, ~s|form:has(input.input-sm) button[phx-click="clear_dates"]|)
|
||||
)
|
||||
render_click(element(lv, ~s|form:has(input.input-sm) button[phx-click="clear_dates"]|))
|
||||
|
||||
refute html =~ ~s(value="2025-01-01")
|
||||
refute html =~ ~s(value="2026-01-01")
|
||||
|
|
|
|||
115
test/microwaveprop_web/live/rover_planning_live_test.exs
Normal file
115
test/microwaveprop_web/live/rover_planning_live_test.exs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
||||
use MicrowavepropWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Microwaveprop.AccountsFixtures
|
||||
alias Microwaveprop.Rover
|
||||
alias Microwaveprop.RoverPlanning
|
||||
alias Microwaveprop.Terrain.ElevationClient
|
||||
|
||||
setup do
|
||||
Req.Test.stub(ElevationClient, fn conn ->
|
||||
params = Plug.Conn.fetch_query_params(conn).query_params
|
||||
lat_count = params["latitude"] |> String.split(",") |> length()
|
||||
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp create_mission(user, name) do
|
||||
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})
|
||||
|
||||
{:ok, mission} =
|
||||
RoverPlanning.create_mission(user, %{
|
||||
"name" => name,
|
||||
"band_mhz" => 10_000,
|
||||
"only_known_good" => true,
|
||||
"rover_height_ft" => 8.0,
|
||||
"station_height_ft" => 30.0,
|
||||
"stations" => %{"0" => %{"input" => "EM12kp", "position" => 0}}
|
||||
})
|
||||
|
||||
mission
|
||||
end
|
||||
|
||||
describe "index" do
|
||||
test "anonymous visitor sees the table and a sign-in prompt", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/rover-planning")
|
||||
assert html =~ "Rover Planning"
|
||||
assert html =~ "Sign in"
|
||||
end
|
||||
|
||||
test "logged-in user sees the New mission button", %{conn: conn} do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
conn = log_in_user(conn, user)
|
||||
{:ok, _lv, html} = live(conn, ~p"/rover-planning")
|
||||
assert html =~ "New mission"
|
||||
end
|
||||
|
||||
test "table shows existing missions and links to the show page", %{conn: conn} do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
mission = create_mission(user, "Visible mission")
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/rover-planning")
|
||||
assert html =~ "Visible mission"
|
||||
assert html =~ ~s(href="/rover-planning/#{mission.id}")
|
||||
end
|
||||
end
|
||||
|
||||
describe "show" do
|
||||
test "renders mission details + path table", %{conn: conn} do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
mission = create_mission(user, "Detail mission")
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||||
assert html =~ "Detail mission"
|
||||
assert html =~ "Stationary stations"
|
||||
assert html =~ "Path profiles"
|
||||
end
|
||||
|
||||
test "redirects when mission missing", %{conn: conn} do
|
||||
missing = Ecto.UUID.generate()
|
||||
|
||||
assert {:error, {:live_redirect, %{to: "/rover-planning"}}} =
|
||||
live(conn, ~p"/rover-planning/#{missing}")
|
||||
end
|
||||
end
|
||||
|
||||
describe "form" do
|
||||
test "anonymous user gets redirected to /rover-planning", %{conn: conn} do
|
||||
assert {:error, {:live_redirect, %{to: "/rover-planning"}}} =
|
||||
live(conn, ~p"/rover-planning/new")
|
||||
end
|
||||
|
||||
test "logged-in user can create a mission with a callsign-input station",
|
||||
%{conn: conn} do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})
|
||||
conn = log_in_user(conn, user)
|
||||
|
||||
{:ok, lv, html} = live(conn, ~p"/rover-planning/new")
|
||||
assert html =~ "Only check against known good locations"
|
||||
assert html =~ ~s(checked)
|
||||
|
||||
{:error, {:live_redirect, %{to: redirect_to}}} =
|
||||
lv
|
||||
|> form("#mission-form",
|
||||
mission: %{
|
||||
name: "From form",
|
||||
band_mhz: "10000",
|
||||
only_known_good: "true",
|
||||
rover_height_ft: "8.0",
|
||||
station_height_ft: "30.0",
|
||||
stations: %{
|
||||
"0" => %{input: "EM12kp"}
|
||||
}
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert redirect_to =~ ~r{^/rover-planning/[0-9a-f-]+$}
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue