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
242 lines
6.9 KiB
Text
242 lines
6.9 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="coverage"
|
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
|
>
|
|
<.breadcrumb items={[
|
|
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
|
|
%{label: t("Coverage"), navigate: ~p"/coverage"},
|
|
%{label: @page_title}
|
|
]} />
|
|
|
|
<.header>
|
|
{@page_title}
|
|
<:subtitle>
|
|
{t(
|
|
"Configure the radio and antenna. Coverage will be computed in the background once you save."
|
|
)}
|
|
</:subtitle>
|
|
<:actions>
|
|
<span class="inline-flex items-center px-3 py-1.5 rounded-md border border-blue-200 dark:border-blue-900/50 bg-blue-50 dark:bg-blue-900/20 text-sm">
|
|
<span class="text-blue-700 dark:text-blue-300 font-medium mr-2">EIRP:</span>
|
|
<span class="text-blue-900 dark:text-blue-100 font-mono">
|
|
{Float.round(@computed_eirp, 1)} dBm
|
|
</span>
|
|
</span>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<.form
|
|
for={@form}
|
|
id="coverage-form"
|
|
phx-change="validate"
|
|
phx-submit="save"
|
|
class="mt-6 space-y-8"
|
|
>
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<%!-- Identity --%>
|
|
<div class="space-y-4">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t("Identity")}
|
|
</h3>
|
|
|
|
<.input
|
|
field={@form[:name]}
|
|
type="text"
|
|
label={t("Name")}
|
|
placeholder={t("e.g. North sector 5 GHz")}
|
|
required
|
|
/>
|
|
|
|
<.input
|
|
field={@form[:site_id]}
|
|
type="select"
|
|
label={t("Site")}
|
|
prompt={t("Select a site")}
|
|
options={Enum.map(@sites, &{&1.name, &1.id})}
|
|
required
|
|
/>
|
|
|
|
<.input
|
|
:if={@devices != []}
|
|
field={@form[:device_id]}
|
|
type="select"
|
|
label={t("Attach to device (optional)")}
|
|
prompt={t("No specific device")}
|
|
options={Enum.map(@devices, &{device_label(&1), &1.id})}
|
|
/>
|
|
<p
|
|
:if={@form[:site_id].value not in [nil, ""] and @devices == []}
|
|
class="text-xs text-gray-500 dark:text-gray-400"
|
|
>
|
|
{t("This site has no devices yet — coverage will not be attached to a specific device.")}
|
|
</p>
|
|
|
|
<.input
|
|
field={@form[:antenna_slug]}
|
|
type="select"
|
|
label={t("Antenna")}
|
|
prompt={t("Select an antenna")}
|
|
options={@antenna_options}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<%!-- Radio location: draggable marker on a Leaflet map. --%>
|
|
<div class="space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t("Radio location")}
|
|
</h3>
|
|
<button
|
|
:if={radio_overridden?(@form)}
|
|
type="button"
|
|
phx-click="use_site_location"
|
|
class="text-xs text-blue-600 hover:underline dark:text-blue-400"
|
|
>
|
|
{t("Use site location")}
|
|
</button>
|
|
</div>
|
|
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
{t("Drag the marker to where the radio is mounted. Defaults to the site's coordinates.")}
|
|
</p>
|
|
|
|
<div
|
|
id="coverage-location-picker"
|
|
phx-hook="CoverageLocationPicker"
|
|
phx-update="ignore"
|
|
class="h-72 w-full rounded border border-gray-200 dark:border-white/10 bg-gray-100 dark:bg-gray-800"
|
|
data-marker-lat={radio_marker_lat(@form, @sites)}
|
|
data-marker-lon={radio_marker_lon(@form, @sites)}
|
|
>
|
|
</div>
|
|
|
|
<div
|
|
:if={radio_marker_lat(@form, @sites)}
|
|
class="text-xs font-mono text-gray-600 dark:text-gray-400"
|
|
>
|
|
{radio_marker_lat(@form, @sites)}, {radio_marker_lon(@form, @sites)}
|
|
<span :if={radio_overridden?(@form)} class="ml-2 text-blue-600 dark:text-blue-400">
|
|
({t("override")})
|
|
</span>
|
|
</div>
|
|
|
|
<.input field={@form[:latitude_override]} type="hidden" />
|
|
<.input field={@form[:longitude_override]} type="hidden" />
|
|
</div>
|
|
|
|
<%!-- Radio --%>
|
|
<div class="space-y-4">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t("Radio")}
|
|
</h3>
|
|
|
|
<.input
|
|
field={@form[:frequency_ghz]}
|
|
type="number"
|
|
label={t("Frequency (GHz)")}
|
|
step="0.001"
|
|
min="0.7"
|
|
max="90"
|
|
required
|
|
/>
|
|
|
|
<.input
|
|
field={@form[:tx_power_dbm]}
|
|
type="number"
|
|
label={t("TX power (dBm)")}
|
|
step="0.1"
|
|
min="-10"
|
|
max="50"
|
|
required
|
|
/>
|
|
|
|
<.input
|
|
field={@form[:radius_mi]}
|
|
type="number"
|
|
label={t("Range (mi)")}
|
|
step="0.1"
|
|
min="0.3"
|
|
max="25"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<%!-- Mounting --%>
|
|
<div class="space-y-4">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t("Mounting")}
|
|
</h3>
|
|
|
|
<.input
|
|
field={@form[:height_agl_ft]}
|
|
type="number"
|
|
label={t("Height above ground (ft)")}
|
|
step="1"
|
|
min="3"
|
|
max="650"
|
|
required
|
|
/>
|
|
|
|
<.input
|
|
field={@form[:azimuth_deg]}
|
|
type="number"
|
|
label={t("Azimuth (°, true north)")}
|
|
step="1"
|
|
min="0"
|
|
max="360"
|
|
required
|
|
/>
|
|
|
|
<.input
|
|
field={@form[:downtilt_deg]}
|
|
type="number"
|
|
label={t("Tilt (°, positive = down)")}
|
|
step="0.1"
|
|
min="-10"
|
|
max="30"
|
|
/>
|
|
</div>
|
|
|
|
<%!-- Foliage --%>
|
|
<div class="space-y-4">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t("Environment")}
|
|
</h3>
|
|
|
|
<div>
|
|
<label
|
|
for="coverage_foliage_tuning"
|
|
class="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-1"
|
|
>
|
|
{t("Foliage tuning")}: {Phoenix.HTML.Form.input_value(@form, :foliage_tuning) || 0}
|
|
</label>
|
|
<input
|
|
type="range"
|
|
id="coverage_foliage_tuning"
|
|
name="coverage[foliage_tuning]"
|
|
value={Phoenix.HTML.Form.input_value(@form, :foliage_tuning) || 0}
|
|
min="0"
|
|
max="100"
|
|
step="1"
|
|
class="w-full"
|
|
/>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
{t("Higher values reduce predicted signal in wooded areas.")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-2">
|
|
<.link navigate={~p"/coverage"} class="btn btn-outline">
|
|
{t("Cancel")}
|
|
</.link>
|
|
<.button type="submit" variant="primary">
|
|
{if @live_action == :new, do: t("Create coverage"), else: t("Save changes")}
|
|
</.button>
|
|
</div>
|
|
</.form>
|
|
</Layouts.authenticated>
|