- 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.
240 lines
9.3 KiB
Text
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>
|