towerops/lib/towerops_web/live/coverage_live/show.ex
Graham McIntire 5a9381f91a feat: add /coverage RF prediction feature scaffold
End-to-end CRUD scaffold for per-site, per-antenna RF coverage
prediction. Schema, context, antenna registry, Oban worker stub, and
three LiveViews are wired up; the actual ITM + LIDAR + buildings
compute pipeline lands in a follow-up.

- Migration + Coverage schema with full validation (RF params, pixel-
  budget cap, antenna_slug existence). organization_id excluded from
  cast (programmatic-only).
- Coverages context: org-scoped CRUD, validate_site_in_organization
  rejects cross-tenant site_id refs at insert/update, queue_compute
  with PubSub broadcast on per-coverage and per-org topics.
- MSI Planet .ant parser + persistent_term registry loaded at boot
  from priv/antennas/. Two synthetic .ant fixtures (omni + 90deg
  sector) bundled.
- CoverageWorker on new :coverage Oban queue (concurrency 2). Job
  args carry organization_id; worker refuses to run on org mismatch.
  Stub flips status to "failed" with "compute not yet implemented"
  so the UI flow is exercisable.
- LiveViews: index (org-wide, live status updates), form (antenna
  picker grouped by manufacturer), show (status banners, params
  panel, map placeholder). Sidebar nav link added.
- Routes /coverage, /coverage/new, /coverage/:id, /coverage/:id/edit
  under :require_authenticated_user_with_default_org.
- 41 new tests covering schema validation, .ant parsing, context
  CRUD with cross-org guards, Oban job enqueue.
2026-05-06 13:44:14 -05:00

89 lines
2.8 KiB
Elixir

defmodule ToweropsWeb.CoverageLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Coverages
alias Towerops.Coverages.Antenna
@impl true
def mount(%{"id" => id}, _session, socket) do
organization = socket.assigns.current_scope.organization
coverage = Coverages.get_coverage!(organization.id, id)
if connected?(socket), do: Coverages.subscribe(coverage.id)
{:ok,
socket
|> assign(:page_title, coverage.name)
|> assign(:organization, organization)
|> assign(:coverage, coverage)
|> assign(:antenna, Antenna.get(coverage.antenna_slug))}
end
@impl true
def handle_params(_params, _url, socket), do: {:noreply, socket}
@impl true
def handle_event("recompute", _params, socket) do
case Coverages.queue_compute(socket.assigns.coverage) do
{:ok, coverage} ->
{:noreply,
socket
|> assign(:coverage, coverage)
|> put_flash(:info, t("Coverage queued for recompute"))}
{:error, :compute_in_progress} ->
{:noreply, put_flash(socket, :error, t("Coverage is already being computed"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to queue coverage"))}
end
end
@impl true
def handle_event("delete", _params, socket) do
case Coverages.delete_coverage(socket.assigns.coverage) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage deleted"))
|> push_navigate(to: ~p"/coverage")}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Unable to delete coverage"))}
end
end
@impl true
def handle_info({:coverage_status, _status, _extra}, socket) do
coverage =
Coverages.get_coverage!(socket.assigns.organization.id, socket.assigns.coverage.id)
{:noreply, assign(socket, :coverage, coverage)}
end
@doc false
def status_badge_class("draft"), do: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
def status_badge_class("queued"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
def status_badge_class("computing"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
def status_badge_class("ready"), do: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
def status_badge_class("failed"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
def status_badge_class(_), do: "bg-gray-100 text-gray-700"
attr :label, :string, required: true
attr :value, :string, required: true
def param_row(assigns) do
~H"""
<div class="flex justify-between gap-3">
<dt class="text-gray-600 dark:text-gray-400">{@label}</dt>
<dd class="text-gray-900 dark:text-gray-100 font-medium text-right">{@value}</dd>
</div>
"""
end
end