towerops/lib/towerops_web/live/coverage_live/show.html.heex
Graham McIntire ef4a4c99b5 fix(coverage): production show-page crash and stale device on PubSub refresh
- show.html.heex: `@coverage.device_id and ...` raised BadBooleanError
  in production because device_id is a UUID string, not a boolean. Use
  is_nil/1 checks instead.
- show.ex: handle_info/2 was reloading the coverage without preloading
  :device, so the device row vanished from the UI on every status
  broadcast. Preload :device alongside the reload.
- coverage_worker: thread the precomputed tx_height through the pixel
  loop instead of re-deriving `coverage.height_agl_m + ...` per pixel
  with inconsistent nil-safety vs. compute_pixels/6.
2026-05-06 15:19:32 -05:00

240 lines
9.3 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">
<%= cond do %>
<% @coverage.png_path && @coverage.status == "ready" -> %>
<div
id={"coverage-map-#{@coverage.id}"}
class="h-[500px] w-full bg-gray-100 dark:bg-gray-800"
phx-hook="CoverageMap"
phx-update="ignore"
data-name={@coverage.name}
data-lat={coverage_lat(@coverage)}
data-lon={coverage_lon(@coverage)}
data-azimuth={@coverage.azimuth_deg}
data-png={@coverage.png_path}
data-bbox={
Jason.encode!([
@coverage.bbox_min_lat,
@coverage.bbox_min_lon,
@coverage.bbox_max_lat,
@coverage.bbox_max_lon
])
}
>
</div>
<%!-- Opacity slider + cnHeat-style legend --%>
<div class="p-3 border-t border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 space-y-3">
<div class="flex items-center gap-3">
<label
for="coverage-opacity-slider"
class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap"
>
{t("Overlay opacity")}
</label>
<input
id="coverage-opacity-slider"
type="range"
min="0"
max="100"
step="5"
value="70"
class="flex-1"
/>
<span
id="coverage-opacity-label"
class="text-sm font-mono text-gray-700 dark:text-gray-300 w-12 text-right"
>
70%
</span>
</div>
<div class="flex items-center gap-1 text-xs">
<span class="text-gray-500 dark:text-gray-400 mr-2">{t("Signal:")}</span>
<span class="px-2 py-0.5 rounded text-white" style="background:rgba(0,200,0,0.9)">
≥ -55 dBm
</span>
<span
class="px-2 py-0.5 rounded text-white"
style="background:rgba(200,230,0,0.9)"
>
-65
</span>
<span
class="px-2 py-0.5 rounded text-white"
style="background:rgba(255,165,0,0.9)"
>
-75
</span>
<span
class="px-2 py-0.5 rounded text-white"
style="background:rgba(220,60,60,0.9)"
>
-85
</span>
<span class="px-2 py-0.5 rounded text-white" style="background:rgba(100,0,0,0.9)">
-95
</span>
<span class="text-gray-500 dark:text-gray-400 ml-1">
{t("(weaker is darker)")}
</span>
</div>
</div>
<% true -> %>
<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={
not is_nil(@coverage.device_id) and Ecto.assoc_loaded?(@coverage.device) and
not is_nil(@coverage.device)
}
label={t("Device")}
value={@coverage.device.name || @coverage.device.ip_address}
/>
<.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("Frequency")} value={format_ghz(@coverage.frequency_mhz)} />
<.param_row label={t("TX power")} value={"#{@coverage.tx_power_dbm} dBm"} />
<.param_row label={t("EIRP")} value={"#{Float.round(@eirp_dbm, 1)} dBm"} />
<.param_row label={t("Height above ground")} value={format_ft(@coverage.height_agl_m)} />
<.param_row label={t("Azimuth")} value={"#{@coverage.azimuth_deg}°"} />
<.param_row label={t("Tilt")} value={"#{@coverage.downtilt_deg}°"} />
<.param_row label={t("Range")} value={format_mi(@coverage.radius_m)} />
<.param_row
:if={@coverage.foliage_tuning && @coverage.foliage_tuning > 0}
label={t("Foliage tuning")}
value={"#{@coverage.foliage_tuning}"}
/>
<.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>