161 lines
5 KiB
Elixir
161 lines
5 KiB
Elixir
defmodule MicrowavepropWeb.PskrSpotsLive do
|
||
@moduledoc "`/pskreporter` — recent PSK Reporter spot aggregates. Refreshes every 60s."
|
||
use MicrowavepropWeb, :live_view
|
||
|
||
import Ecto.Query
|
||
|
||
alias Microwaveprop.Format
|
||
alias Microwaveprop.Pskr.SpotHourly
|
||
alias Microwaveprop.Repo
|
||
|
||
@limit 100
|
||
@refresh_ms 60_000
|
||
|
||
@impl true
|
||
def mount(_params, _session, socket) do
|
||
_ = if connected?(socket), do: schedule_refresh()
|
||
|
||
spots = fetch_recent_spots()
|
||
socket = start_async(socket, :total_spots, fn -> fetch_total_spots() end)
|
||
socket = start_async(socket, :band_counts, fn -> fetch_band_counts() end)
|
||
|
||
{:ok,
|
||
socket
|
||
|> assign(
|
||
page_title: "PSK Reporter Spots",
|
||
limit: @limit,
|
||
total_spots: nil,
|
||
band_counts: [],
|
||
spots_empty: spots == []
|
||
)
|
||
|> stream(:spots, spots, reset: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_async(:total_spots, {:ok, total}, socket) do
|
||
{:noreply, assign(socket, total_spots: total)}
|
||
end
|
||
|
||
def handle_async(:band_counts, {:ok, counts}, socket) do
|
||
{:noreply, assign(socket, band_counts: counts)}
|
||
end
|
||
|
||
def handle_async(slot, {:exit, reason}, socket) do
|
||
Logger.warning("PskrSpotsLive async #{slot} failed: #{inspect(reason)}")
|
||
{:noreply, socket}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info(:refresh_spots, socket) do
|
||
_ = schedule_refresh()
|
||
spots = fetch_recent_spots()
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(
|
||
total_spots: fetch_total_spots(),
|
||
band_counts: fetch_band_counts(),
|
||
spots_empty: spots == []
|
||
)
|
||
|> stream(:spots, spots, reset: true)}
|
||
end
|
||
|
||
defp schedule_refresh do
|
||
Process.send_after(self(), :refresh_spots, @refresh_ms)
|
||
end
|
||
|
||
defp fetch_recent_spots do
|
||
SpotHourly
|
||
|> order_by(desc: :last_spot_at)
|
||
|> limit(@limit)
|
||
|> Repo.all()
|
||
end
|
||
|
||
defp fetch_total_spots do
|
||
Repo.one(from(s in SpotHourly, select: coalesce(sum(s.spot_count), 0)))
|
||
end
|
||
|
||
defp fetch_band_counts do
|
||
Repo.all(
|
||
from(s in SpotHourly,
|
||
group_by: s.band,
|
||
select: {s.band, sum(s.spot_count)},
|
||
order_by: [desc: sum(s.spot_count)]
|
||
)
|
||
)
|
||
end
|
||
|
||
defp fmt_dt(nil), do: "—"
|
||
defp fmt_dt(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
|
||
|
||
defp fmt_snr(min, max) when min == max, do: "#{min} dB"
|
||
defp fmt_snr(min, max), do: "#{min}–#{max} dB"
|
||
|
||
defp fmt_callsigns([]), do: "—"
|
||
defp fmt_callsigns(calls), do: Enum.join(calls, ", ")
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
~H"""
|
||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-none">
|
||
<.header>
|
||
PSK Reporter Spots
|
||
<:subtitle>Last {@limit} spot aggregates from the MQTT firehose</:subtitle>
|
||
</.header>
|
||
|
||
<div class="flex flex-wrap items-baseline gap-x-6 gap-y-1 text-sm mb-4">
|
||
<span class="font-semibold">
|
||
Total spots stored: <span class="font-mono">{Format.number(@total_spots)}</span>
|
||
</span>
|
||
<span :for={{band, count} <- @band_counts} class="text-base-content/70">
|
||
<span class="badge badge-sm mr-1">{band}</span>
|
||
<span class="font-mono">{Format.number(count)}</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="overflow-x-auto">
|
||
<table class="table table-sm table-zebra whitespace-nowrap">
|
||
<thead>
|
||
<tr>
|
||
<th>Last Heard (UTC)</th>
|
||
<th>Band</th>
|
||
<th>From</th>
|
||
<th>From Grid</th>
|
||
<th>To</th>
|
||
<th>To Grid</th>
|
||
<th>Distance</th>
|
||
<th class="text-right">Spots</th>
|
||
<th>SNR</th>
|
||
<th>Modes</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="pskr-spots" phx-update="stream">
|
||
<tr :for={{dom_id, spot} <- @streams.spots} id={dom_id}>
|
||
<td class="text-xs font-mono">{fmt_dt(spot.last_spot_at)}</td>
|
||
<td class="text-xs"><span class="badge badge-sm">{spot.band}</span></td>
|
||
<td class="text-xs font-mono whitespace-nowrap">
|
||
{fmt_callsigns(spot.sender_callsigns)}
|
||
</td>
|
||
<td class="text-xs font-mono">{spot.sender_grid}</td>
|
||
<td class="text-xs font-mono whitespace-nowrap">
|
||
{fmt_callsigns(spot.receiver_callsigns)}
|
||
</td>
|
||
<td class="text-xs font-mono">{spot.receiver_grid}</td>
|
||
<td class="text-xs font-mono">{Format.distance_km(spot.distance_km)}</td>
|
||
<td class="text-xs text-right font-mono">{Format.number(spot.spot_count)}</td>
|
||
<td class="text-xs font-mono">{fmt_snr(spot.min_snr_db, spot.max_snr_db)}</td>
|
||
<td class="text-xs">
|
||
<span :for={mode <- spot.modes} class="badge badge-xs mr-0.5">{mode}</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<p :if={@spots_empty && @total_spots == 0} class="text-sm text-base-content/50 italic mt-4">
|
||
No PSK Reporter spots received yet. The MQTT listener is running — spots will appear here as they arrive.
|
||
</p>
|
||
</Layouts.app>
|
||
"""
|
||
end
|
||
end
|