Uncommitted work from previous session: - Add list_users_select/0 helper for admin select dropdowns - Add regenerate_token/1 context function - Preload assigned_by association on monitor queries - Add admin nav link to beacon monitors - Fix JS event targets (remove redundant target: @myself) - Add reassign user form to admin monitor show page - Add regenerate token button to admin monitor show page - Show assigned_by admin on monitor detail page - Add beacon monitors section to user management edit page - Add unassign monitor capability from user management - Add full test coverage for all new functionality - Fix pre-existing /account route warning (redirect → /users/settings)
343 lines
12 KiB
Elixir
343 lines
12 KiB
Elixir
defmodule MicrowavepropWeb.Admin.MonitorLive.Show do
|
|
@moduledoc "Admin beacon-monitor detail at `/admin/beacon-monitors/:id`."
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
import Ecto.Query
|
|
|
|
alias Microwaveprop.Accounts
|
|
alias Microwaveprop.BeaconMeasurements
|
|
alias Microwaveprop.BeaconMonitors
|
|
alias Microwaveprop.BeaconMonitors.BeaconMonitor
|
|
alias Microwaveprop.Beacons.Beacon
|
|
alias Microwaveprop.Repo
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
monitor = BeaconMonitors.get_monitor!(id)
|
|
beacons = Repo.all(from b in Beacon, order_by: b.callsign)
|
|
users = Accounts.list_users_select()
|
|
measurements = BeaconMeasurements.list_recent_for_monitor(monitor.id, 20)
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, "Monitor: #{monitor.name}")
|
|
|> assign(:monitor, monitor)
|
|
|> assign(:beacons, beacons)
|
|
|> assign(:users, users)
|
|
|> assign(:measurements, measurements)
|
|
|> assign_form(monitor)
|
|
|> assign_reassign_form(monitor)}
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-5xl">
|
|
<.header>
|
|
Monitor: {@monitor.name}
|
|
<:subtitle>Hardware detail and configuration</:subtitle>
|
|
</.header>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
|
<div class="card bg-base-200 shadow-sm">
|
|
<div class="card-body">
|
|
<h2 class="card-title text-sm">Hardware</h2>
|
|
<dl class="text-sm space-y-1">
|
|
<div class="flex justify-between">
|
|
<dt class="opacity-70">Type</dt>
|
|
<dd>{@monitor.hardware_type}</dd>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<dt class="opacity-70">ID / Serial</dt>
|
|
<dd>{@monitor.hardware_id}</dd>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<dt class="opacity-70">Firmware</dt>
|
|
<dd>{@monitor.firmware_version}</dd>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<dt class="opacity-70">Antenna</dt>
|
|
<dd>{@monitor.antenna_type}</dd>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<dt class="opacity-70">Antenna gain</dt>
|
|
<dd>{@monitor.antenna_gain_dbi && "#{@monitor.antenna_gain_dbi} dBi"}</dd>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<dt class="opacity-70">Location</dt>
|
|
<dd>
|
|
<%= if @monitor.lat && @monitor.lon do %>
|
|
{@monitor.lat}, {@monitor.lon}
|
|
<% else %>
|
|
<span class="opacity-50">—</span>
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-200 shadow-sm">
|
|
<div class="card-body">
|
|
<h2 class="card-title text-sm">Assignment</h2>
|
|
<dl class="text-sm space-y-1">
|
|
<div class="flex justify-between">
|
|
<dt class="opacity-70">Assigned to</dt>
|
|
<dd>
|
|
<%= if @monitor.user do %>
|
|
<.link
|
|
navigate={~p"/users/#{@monitor.user.id}/edit"}
|
|
class="link link-hover font-mono"
|
|
>
|
|
{@monitor.user.callsign}
|
|
</.link>
|
|
<% else %>
|
|
<span class="opacity-50">Unassigned</span>
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<dt class="opacity-70">Assigned by</dt>
|
|
<dd>
|
|
<%= if @monitor.assigned_by do %>
|
|
<.link
|
|
navigate={~p"/users/#{@monitor.assigned_by.id}/edit"}
|
|
class="link link-hover font-mono"
|
|
>
|
|
{@monitor.assigned_by.callsign}
|
|
</.link>
|
|
<% else %>
|
|
<span class="opacity-50">—</span>
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<dt class="opacity-70">Auth token</dt>
|
|
<dd>
|
|
<code class="text-xs select-all break-all">{@monitor.token}</code>
|
|
<.button
|
|
id="regenerate-token-btn"
|
|
phx-click={JS.push("regenerate-token")}
|
|
data-confirm="Regenerate token? The monitor will need the new token to connect."
|
|
class="btn btn-xs btn-ghost ml-1"
|
|
>
|
|
Regenerate
|
|
</.button>
|
|
</dd>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<dt class="opacity-70">Last seen</dt>
|
|
<dd class="opacity-70">
|
|
<%= if @monitor.last_seen_at do %>
|
|
{Calendar.strftime(@monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
|
|
<% else %>
|
|
never
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div class="border-t border-base-300 pt-3 mt-3">
|
|
<.form for={@reassign_form} id="reassign-form" phx-submit="reassign">
|
|
<div class="flex items-end gap-2">
|
|
<div class="flex-1">
|
|
<.input
|
|
field={@reassign_form[:user_id]}
|
|
type="select"
|
|
label="Reassign to"
|
|
options={@users}
|
|
prompt="Select user"
|
|
/>
|
|
</div>
|
|
<.button variant="primary" phx-disable-with="Reassigning...">
|
|
Reassign
|
|
</.button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-200 shadow-sm mb-6">
|
|
<div class="card-body">
|
|
<h2 class="card-title text-sm">Configuration</h2>
|
|
<.form for={@form} id="config-form" phx-submit="update-config">
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<.input field={@form[:name]} type="text" label="Name" required />
|
|
<.input
|
|
field={@form[:beacon_id]}
|
|
type="select"
|
|
label="Beacon to monitor"
|
|
options={beacon_options(@beacons)}
|
|
prompt="Select beacon"
|
|
/>
|
|
<.input field={@form[:callsign]} type="text" label="Callsign" />
|
|
<p class="text-xs opacity-60 -mt-2">For unregistered operators</p>
|
|
<.input field={@form[:lat]} type="number" label="Latitude" step="any" />
|
|
<.input field={@form[:lon]} type="number" label="Longitude" step="any" />
|
|
<.input field={@form[:config_frequency_hz]} type="number" label="Frequency (Hz)" />
|
|
<p class="text-xs opacity-60 -mt-2">Override — leave blank to use beacon's frequency</p>
|
|
<.input field={@form[:config_integration_s]} type="number" label="Integration (s)" />
|
|
<p class="text-xs opacity-60 -mt-2">Override — leave blank for default (60s)</p>
|
|
<.input
|
|
field={@form[:config_mode]}
|
|
type="text"
|
|
label="Mode"
|
|
placeholder="e.g. wspr, q65a_30"
|
|
/>
|
|
</div>
|
|
<footer class="mt-4">
|
|
<.button variant="primary" phx-disable-with="Saving...">Save configuration</.button>
|
|
</footer>
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-200 shadow-sm">
|
|
<div class="card-body">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h2 class="card-title text-sm">Recent measurements</h2>
|
|
<.link navigate={~p"/admin/beacon-monitors"} class="btn btn-ghost btn-xs">
|
|
← Back to monitors
|
|
</.link>
|
|
</div>
|
|
<%= if @measurements == [] do %>
|
|
<p class="text-sm opacity-70">No measurements yet.</p>
|
|
<% else %>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Frequency</th>
|
|
<th>SNR avg</th>
|
|
<th>SNR peak</th>
|
|
<th>Noise floor</th>
|
|
<th>Gain</th>
|
|
<th>Integration</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={m <- @measurements}>
|
|
<td class="whitespace-nowrap text-xs">
|
|
{Calendar.strftime(m.measured_at, "%H:%M UTC")}
|
|
</td>
|
|
<td class="text-xs">{format_freq(m.frequency_hz)}</td>
|
|
<td class="text-xs">{Float.round(m.snr_avg_db, 1)} dB</td>
|
|
<td class="text-xs">{Float.round(m.snr_peak_db, 1)} dB</td>
|
|
<td class="text-xs">{Float.round(m.noise_floor_dbfs, 1)} dBFS</td>
|
|
<td class="text-xs">{Float.round(m.gain_db, 1)} dB</td>
|
|
<td class="text-xs">{m.integration_s}s</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("update-config", %{"beacon_monitor" => params}, socket) do
|
|
monitor = socket.assigns.monitor
|
|
|
|
case BeaconMonitors.update_config(monitor, params) do
|
|
{:ok, updated} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:monitor, updated)
|
|
|> assign_form(updated)
|
|
|> put_flash(:info, "Configuration updated.")}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply, assign(socket, :form, to_form(changeset, as: :beacon_monitor))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("reassign", %{"beacon_monitor" => %{"user_id" => user_id}}, socket)
|
|
when is_binary(user_id) and user_id != "" do
|
|
admin = socket.assigns.current_scope.user
|
|
monitor = socket.assigns.monitor
|
|
|
|
case Accounts.get_user!(user_id) do
|
|
nil ->
|
|
{:noreply, put_flash(socket, :error, "User not found.")}
|
|
|
|
target ->
|
|
case BeaconMonitors.assign_to_user(monitor, admin, target) do
|
|
{:ok, updated} ->
|
|
updated = Repo.preload(updated, [:user, :assigned_by])
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:monitor, updated)
|
|
|> assign_reassign_form(updated)
|
|
|> put_flash(:info, "Monitor reassigned to #{target.callsign}.")}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign_reassign_form(monitor, changeset)
|
|
|> put_flash(:error, "Failed to reassign monitor.")}
|
|
end
|
|
end
|
|
end
|
|
|
|
def handle_event("reassign", _params, socket) do
|
|
{:noreply, put_flash(socket, :error, "Please select a user.")}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("regenerate-token", _params, socket) do
|
|
monitor = socket.assigns.monitor
|
|
|
|
case BeaconMonitors.regenerate_token(monitor) do
|
|
{:ok, updated} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:monitor, updated)
|
|
|> put_flash(:info, "Auth token regenerated.")}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :error, "Failed to regenerate token.")}
|
|
end
|
|
end
|
|
|
|
defp assign_form(socket, monitor) do
|
|
assign(socket, :form, to_form(BeaconMonitor.config_changeset(monitor, %{}), as: :beacon_monitor))
|
|
end
|
|
|
|
defp assign_reassign_form(socket, monitor, changeset \\ nil) do
|
|
form =
|
|
if changeset do
|
|
to_form(changeset, as: :beacon_monitor)
|
|
else
|
|
to_form(%{"user_id" => monitor.user_id}, as: :beacon_monitor)
|
|
end
|
|
|
|
assign(socket, :reassign_form, form)
|
|
end
|
|
|
|
defp beacon_options(beacons) do
|
|
Enum.map(beacons, &{"#{&1.callsign} @ #{Beacon.format_freq(&1.frequency_mhz)} MHz", &1.id})
|
|
end
|
|
|
|
defp format_freq(hz) when is_integer(hz) and hz >= 1_000_000 do
|
|
mhz =
|
|
(hz / 1_000_000)
|
|
|> Float.round(3)
|
|
|> then(fn
|
|
n when n == trunc(n) -> trunc(n)
|
|
n -> n
|
|
end)
|
|
|
|
"#{mhz} MHz"
|
|
end
|
|
|
|
defp format_freq(hz) when is_integer(hz), do: "#{hz} Hz"
|
|
defp format_freq(_), do: ""
|
|
end
|