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.
159 lines
6.4 KiB
Text
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>
|