After picking a site in the coverage form, a new optional dropdown lets the user attach the coverage to one of the devices at that site. The dropdown reloads on every site change. If the site has no devices yet, a hint replaces it. Schema: nullable device_id FK on coverages (on_delete: nilify_all so deleting a device leaves the coverage standing). Filtered by both site_id and organization_id when loading the dropdown options. Show page lists the attached device by name (or IP) when set.
239 lines
9.2 KiB
Text
239 lines
9.2 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={
|
|
@coverage.device_id and Ecto.assoc_loaded?(@coverage.device) and @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>
|