aprs.me/lib/aprsme_web/live/packets_live/index.ex
Graham McIntire 7e1498bf4e
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 50s
perf: batch high-frequency LiveView updates via socket.private
Buffer incoming packets in socket.private (no rerender) and flush to
assigns/stream on a 150ms timer, preventing client-side morphdom from
being overwhelmed during traffic bursts. Applies to the global packet
feed (stream) and per-callsign detail view (assigns).
2026-07-22 11:23:09 -05:00

120 lines
3.6 KiB
Elixir

defmodule AprsmeWeb.PacketsLive.Index do
@moduledoc false
use AprsmeWeb, :live_view
use Gettext, backend: AprsmeWeb.Gettext
alias Aprsme.EncodingUtils
@flush_interval_ms 150
@max_stream_size 100
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
end
{:ok,
socket
|> put_private(:packet_buffer, [])
|> put_private(:flush_timer, nil)
|> stream(:packets, [], at: 0)}
end
@impl true
def handle_info({:postgres_packet, payload}, socket) do
# Buffer in socket.private to avoid triggering a rerender on every packet.
# A periodic flush timer drains the buffer and batch-inserts into the stream,
# which keeps client-side morphdom from being overwhelmed during high-traffic bursts.
socket = update_in(socket.private.packet_buffer, &[payload | &1])
socket = ensure_flush_timer(socket)
{:noreply, socket}
end
@impl true
def handle_info(:flush_packets, socket) do
packets = Enum.reverse(socket.private.packet_buffer)
socket = put_private(socket, :packet_buffer, [])
socket = put_private(socket, :flush_timer, nil)
# Batch insert all accumulated packets into the stream.
# Processing newest-first with at: 0 preserves descending-chronological order.
socket =
Enum.reduce(packets, socket, fn payload, s ->
sanitized = EncodingUtils.sanitize_packet(payload)
stream_insert(s, :packets, sanitized, at: 0)
end)
# Trim stream to keep at most @max_stream_size items.
socket = trim_stream(socket)
{:noreply, socket}
end
defp ensure_flush_timer(socket) do
if socket.private.flush_timer do
socket
else
timer = Process.send_after(self(), :flush_packets, @flush_interval_ms)
put_private(socket, :flush_timer, timer)
end
end
defp trim_stream(socket) do
stream = socket.assigns.streams.packets
count = Enum.count(stream)
if count > @max_stream_size do
excess = count - @max_stream_size
# Remove excess from the end (oldest items)
to_remove = stream |> Enum.reverse() |> Enum.take(excess)
Enum.reduce(to_remove, socket, fn {id, _}, s ->
stream_delete(s, :packets, id)
end)
else
socket
end
end
@doc """
Extract a coordinate (:lat or :lon) from a packet, checking multiple sources:
1. Direct :lat/:lon keys
2. Packet struct with PostGIS location
3. data_extended map with :latitude/:longitude keys
"""
def extract_coordinate(packet, which) when which in [:lat, :lon] do
direct_key = which
extended_key = if which == :lat, do: :latitude, else: :longitude
Map.get(packet, direct_key) ||
extract_from_location(packet, which) ||
extract_from_data_extended(packet, extended_key)
end
@doc """
Format a coordinate value for display with up to 6 decimal places.
"""
def format_coordinate(nil), do: ""
def format_coordinate(value) when is_float(value) do
"~.6f" |> :io_lib.format([value]) |> List.to_string()
end
def format_coordinate(value) when is_binary(value) do
Regex.replace(~r/(\d+\.\d{1,6})\d*/, value, "\\1")
end
def format_coordinate(value), do: to_string(value)
defp extract_from_location(%Aprsme.Packet{location: %Geo.Point{coordinates: {_lon, lat}}}, :lat), do: lat
defp extract_from_location(%Aprsme.Packet{location: %Geo.Point{coordinates: {lon, _lat}}}, :lon), do: lon
defp extract_from_location(_, _), do: nil
defp extract_from_data_extended(packet, key) do
case Map.get(packet, :data_extended) do
%{} = data -> Map.get(data, key) || Map.get(data, to_string(key))
_ -> nil
end
end
end