prop/lib/microwaveprop_web/live/pskr_spots_live.ex
Graham McInitre 93b8f881e2 perf: batch fixes for site-wide audit — indexes, N+1, streams, atomicity
- Add index on weather_stations(station_type, lat, lon) for nearby_stations
- Add UPPER(station1/2) expression indexes for callsign search
- Batch sync_network ~3K individual Repo.insert calls into one insert_all
- Batch Oban.insert calls in insert_unique into Oban.insert_all
- Log warnings on station elevation Repo.update errors instead of discarding
- Wrap reconcile_mission_paths delete+insert in Repo.transaction
- BeaconLive.Index: targeted stream_insert/delete instead of full re-query+push_patch
- RoverPlanningLive.Show: re-fetch single path instead of re-querying all
- PskrSpotsLive: convert to LiveView streams, add 60s auto-refresh
- ImportConfetti: add missing phx-update=ignore
2026-07-16 07:31:19 -05:00

150 lines
4.6 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
{:ok,
socket
|> assign(
page_title: "PSK Reporter Spots",
limit: @limit,
total_spots: fetch_total_spots(),
band_counts: fetch_band_counts()
)
|> stream(:spots, spots, reset: true)}
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()
)
|> 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}
class="hidden only:table-row"
>
No spots yet
</tr>
<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 == []} 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