towerops/lib/towerops_web/live/coverage_live/index.html.heex
Graham McIntire 3a408a8dc1 Security hardening + performance fixes across codebase
CRITICAL:
- Membership: remove :role/:org_id/:user_id from mass-assignment cast; use explicit create_changeset/4 and role_update_changeset/2
- GraphQL member resolver: add authorize_invite/3 checking admin/owner role and role hierarchy
- REST invitations controller: add auth check for invite creation

HIGH:
- ApiToken: remove :organization_id/:user_id from cast; use explicit create_changeset/4

MEDIUM:
- Move 8 LiveView Ecto queries into context modules (Admin, Alerts, Coverages, OnCall, Snmp)
- Replace Process.put/Process.get with socket assigns for unresolved_alert_count (user_auth + layouts + 50 templates)
- Add batch get_utilization_for_interfaces/1 to eliminate N+1 capacity queries in device show
- Replace Process.sleep with Process.monitor/assert_receive or Process.send_after in 5 test files

LOW:
- Add handle_params/3 to UserResetPasswordLive, UserRegistrationLive, StatusPageLive
- Remove redundant Repo.preload calls; add preloads to list_site_devices/1
- Fix @impl annotations and credo nesting warnings
2026-06-21 17:40:50 -05:00

167 lines
7.6 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
{@page_title}
<:subtitle>
{t(
"Predict signal coverage for each transmit antenna using LIDAR terrain and building data."
)}
</:subtitle>
<:actions>
<.button :if={@sites != []} navigate={~p"/coverage/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> {t("New Coverage")}
</.button>
</:actions>
</.header>
<%= cond do %>
<% @sites == [] -> %>
<div class="flex items-center justify-center py-16">
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 class="card-title text-gray-900 dark:text-white">{t("Add a site first")}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t(
"Coverage predictions are computed from a site's location. Add a site, then return here to plan its coverage."
)}
</p>
<div class="card-actions mt-2">
<.button navigate={~p"/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> {t("Add Site")}
</.button>
</div>
</div>
</div>
</div>
<% @coverages == [] -> %>
<div class="flex items-center justify-center py-16">
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 class="card-title text-gray-900 dark:text-white">{t("No coverages yet")}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t("Create your first coverage to predict where a tower's antenna reaches.")}
</p>
<div class="card-actions mt-2">
<.button navigate={~p"/coverage/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> {t("New Coverage")}
</.button>
</div>
</div>
</div>
</div>
<% true -> %>
<div class="mt-6 overflow-x-auto rounded-lg border border-gray-200 dark:border-white/10">
<table
id="coverages-table"
class="min-w-full divide-y divide-gray-200 dark:divide-white/10"
>
<thead class="bg-gray-50 dark:bg-gray-800/80">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Name")}
</th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Site")}
</th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Antenna")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Freq (GHz)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("TX (dBm)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Range (mi)")}
</th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Status")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Actions")}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-900 dark:divide-white/10">
<%= for coverage <- @coverages do %>
<tr id={"coverage-#{coverage.id}"}>
<td class="px-4 py-2 text-sm">
<.link
navigate={~p"/coverage/#{coverage.id}"}
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{coverage.name}
</.link>
</td>
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">
<.link navigate={~p"/sites/#{coverage.site_id}"} class="hover:underline">
{coverage.site && coverage.site.name}
</.link>
</td>
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">
<span class="font-mono text-xs">{coverage.antenna_slug}</span>
</td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300 font-mono">
{Float.round(coverage.frequency_mhz / 1000.0, 3)}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300">
{coverage.tx_power_dbm}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300 font-mono">
{Float.round(coverage.radius_m / 1609.344, 2)}
</td>
<td class="px-4 py-2 text-sm">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
status_badge_class(coverage.status)
]}>
{coverage.status}
</span>
</td>
<td class="px-4 py-2 text-sm text-right">
<div class="flex justify-end gap-2">
<.link
navigate={~p"/coverage/#{coverage.id}/edit"}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<.icon name="hero-pencil-square" class="h-4 w-4" />
</.link>
<button
type="button"
phx-click="recompute"
phx-value-id={coverage.id}
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
title={t("Recompute")}
>
<.icon name="hero-arrow-path" class="h-4 w-4" />
</button>
<button
type="button"
phx-click="delete"
phx-value-id={coverage.id}
data-confirm={t("Delete this coverage?")}
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
title={t("Delete")}
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.authenticated>