towerops/lib/towerops_web/live/coverage_live/show.html.heex
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

159 lines
6.4 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
>
<.breadcrumb items={[
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
%{label: t("Coverage"), navigate: ~p"/coverage"},
%{label: @coverage.name}
]} />
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@coverage.name}</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
<.icon name="hero-map-pin" class="h-4 w-4 inline" />
<.link navigate={~p"/sites/#{@coverage.site_id}"} class="hover:underline">
{@coverage.site && @coverage.site.name}
</.link>
</p>
</div>
<div class="flex items-center gap-2">
<span class={[
"inline-flex items-center px-2.5 py-1 rounded text-sm font-medium",
status_badge_class(@coverage.status)
]}>
{@coverage.status}
</span>
<.link navigate={~p"/coverage/#{@coverage.id}/edit"} class="btn btn-outline btn-sm gap-1.5">
<.icon name="hero-pencil-square" class="h-4 w-4" /> {t("Edit")}
</.link>
<.button phx-click="recompute" variant="primary">
<.icon name="hero-arrow-path" class="h-4 w-4" /> {t("Recompute")}
</.button>
</div>
</div>
<%= if @coverage.status == "computing" do %>
<div class="mb-6 rounded-lg border border-yellow-200 bg-yellow-50 dark:border-yellow-900/50 dark:bg-yellow-900/20 p-4">
<div class="flex items-center gap-3">
<.icon
name="hero-arrow-path"
class="h-5 w-5 text-yellow-700 dark:text-yellow-400 animate-spin"
/>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-900 dark:text-yellow-200">
{t("Computing coverage…")}
</p>
<div class="mt-1 h-2 w-full bg-yellow-200 dark:bg-yellow-900 rounded-full overflow-hidden">
<div
class="h-full bg-yellow-600 dark:bg-yellow-500 transition-all"
style={"width: #{@coverage.progress_pct}%"}
>
</div>
</div>
</div>
<span class="text-sm text-yellow-800 dark:text-yellow-300 font-mono">
{@coverage.progress_pct}%
</span>
</div>
</div>
<% end %>
<%= if @coverage.status == "failed" do %>
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 dark:border-red-900/50 dark:bg-red-900/20 p-4">
<div class="flex items-start gap-3">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 text-red-700 dark:text-red-400 flex-shrink-0 mt-0.5"
/>
<div>
<p class="text-sm font-medium text-red-900 dark:text-red-200">
{t("Compute failed")}
</p>
<p :if={@coverage.error_message} class="mt-1 text-sm text-red-800 dark:text-red-300">
{@coverage.error_message}
</p>
</div>
</div>
</div>
<% end %>
<%= if @coverage.status == "queued" do %>
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20 p-4">
<p class="text-sm text-blue-900 dark:text-blue-200">
<.icon name="hero-clock" class="h-4 w-4 inline" />
{t("Coverage is queued. Compute will start shortly.")}
</p>
</div>
<% end %>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<%!-- Map area --%>
<div class="lg:col-span-2">
<div class="rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<%= if @coverage.png_path && @coverage.status == "ready" do %>
<%!-- Real heatmap rendering is implemented in the compute follow-up.
For now this only renders when png_path is set. --%>
<div
id={"coverage-map-#{@coverage.id}"}
class="h-[500px] w-full bg-gray-100 dark:bg-gray-800"
phx-update="ignore"
>
</div>
<% else %>
<div class="h-[500px] w-full flex items-center justify-center bg-gray-50 dark:bg-gray-900 text-center px-6">
<div>
<.icon name="hero-signal-slash" class="h-12 w-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-600 dark:text-gray-400">
{t("No heatmap available yet. Run compute to generate the coverage prediction.")}
</p>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Parameters panel --%>
<div>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
{t("Parameters")}
</h3>
<dl class="space-y-2 text-sm">
<.param_row
label={t("Antenna")}
value={(@antenna && @antenna.model) || @coverage.antenna_slug}
/>
<.param_row :if={@antenna} label={t("Manufacturer")} value={@antenna.manufacturer} />
<.param_row :if={@antenna} label={t("Antenna gain")} value={"#{@antenna.gain_dbi} dBi"} />
<.param_row label={t("Height AGL")} value={"#{@coverage.height_agl_m} m"} />
<.param_row label={t("Azimuth")} value={"#{@coverage.azimuth_deg}°"} />
<.param_row label={t("Downtilt")} value={"#{@coverage.downtilt_deg}°"} />
<.param_row label={t("Frequency")} value={"#{@coverage.frequency_mhz} MHz"} />
<.param_row label={t("EIRP")} value={"#{@coverage.eirp_dbm} dBm"} />
<.param_row label={t("Radius")} value={"#{@coverage.radius_m} m"} />
<.param_row label={t("Cell size")} value={"#{@coverage.cell_size_m} m"} />
<.param_row label={t("Receiver height")} value={"#{@coverage.receiver_height_m} m"} />
<.param_row label={t("RX threshold")} value={"#{@coverage.rx_threshold_dbm} dBm"} />
<.param_row
:if={@coverage.computed_at}
label={t("Computed at")}
value={Calendar.strftime(@coverage.computed_at, "%Y-%m-%d %H:%M UTC")}
/>
</dl>
</div>
<button
type="button"
phx-click="delete"
data-confirm={t("Delete this coverage?")}
class="mt-4 w-full text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
>
<.icon name="hero-trash" class="h-4 w-4 inline" /> {t("Delete coverage")}
</button>
</div>
</div>
</Layouts.authenticated>