prop/lib/microwaveprop_web/live/monitor_live/show.ex

238 lines
8.6 KiB
Elixir

defmodule MicrowavepropWeb.MonitorLive.Show do
@moduledoc "User-facing beacon-monitor detail at `/beacon-monitors/:id`."
use MicrowavepropWeb, :live_view
import Ecto.Query
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
user = socket.assigns.current_scope.user
monitor = BeaconMonitors.get_monitor!(id)
if monitor.user_id == user.id do
beacons = Repo.all(from b in Beacon, where: b.approved == true, order_by: b.callsign)
measurements = BeaconMeasurements.list_recent_for_monitor(monitor.id, 20)
{:ok,
socket
|> assign(:page_title, "My Monitor: #{monitor.name}")
|> assign(:monitor, monitor)
|> assign(:beacons, beacons)
|> assign(:measurements, measurements)
|> assign_form(monitor)}
else
{:ok,
socket
|> put_flash(:error, "You don't have access to that monitor.")
|> push_navigate(to: ~p"/account")}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-4xl">
<.header>
{@monitor.name}
<:subtitle>Your beacon monitor</: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">Serial / ID</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">&mdash;</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">Connection</h2>
<dl class="text-sm space-y-1">
<div class="flex justify-between">
<dt class="opacity-70">Auth token</dt>
<dd><code class="text-xs select-all break-all">{@monitor.token}</code></dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Last seen</dt>
<dd>
<%= if @monitor.last_seen_at do %>
{Calendar.strftime(@monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
<span class="opacity-50">Never</span>
<% end %>
</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Status</dt>
<dd>
<span class={[
"badge badge-sm",
monitor_online?(@monitor) && "badge-success",
!monitor_online?(@monitor) && "badge-warning"
]}>
{if monitor_online?(@monitor), do: "Online", else: "Offline"}
</span>
</dd>
</div>
</dl>
</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>
<p class="text-xs opacity-70 mb-3">Update what your monitor listens for.</p>
<.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[: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</.button>
</footer>
</.form>
</div>
</div>
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-sm mb-2">Recent measurements</h2>
<%= if @measurements == [] do %>
<p class="text-sm opacity-70">
No measurements yet. Once the monitor starts reporting, they'll appear here.
</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
defp monitor_online?(monitor) do
monitor.last_seen_at && DateTime.diff(DateTime.utc_now(), monitor.last_seen_at, :minute) < 30
end
defp assign_form(socket, monitor) do
assign(socket, :form, to_form(BeaconMonitor.config_changeset(monitor, %{}), as: :beacon_monitor))
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