Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 50s
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).
187 lines
6.4 KiB
Elixir
187 lines
6.4 KiB
Elixir
defmodule AprsmeWeb.PacketsLive.CallsignView do
|
|
@moduledoc false
|
|
use AprsmeWeb, :live_view
|
|
|
|
import Ecto.Query
|
|
|
|
alias Aprsme.Callsign
|
|
alias Aprsme.DeviceParser
|
|
alias Aprsme.EncodingUtils
|
|
alias Aprsme.Packet
|
|
alias Aprsme.Repo
|
|
alias AprsmeWeb.Live.SharedPacketHandler
|
|
|
|
@flush_interval_ms 150
|
|
|
|
@impl true
|
|
def mount(%{"callsign" => callsign}, _session, socket) do
|
|
normalized = Callsign.normalize(callsign)
|
|
|
|
if Callsign.valid?(normalized) do
|
|
socket = subscribe_if_connected(socket, normalized)
|
|
stored = get_stored_packets(normalized, 100)
|
|
{:ok, assign_valid(socket, normalized, stored)}
|
|
else
|
|
{:ok, assign_invalid(socket, normalized)}
|
|
end
|
|
end
|
|
|
|
defp subscribe_if_connected(socket, callsign) do
|
|
_ =
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{callsign}")
|
|
end
|
|
|
|
socket
|
|
end
|
|
|
|
defp assign_valid(socket, callsign, stored) do
|
|
socket
|
|
|> assign(:callsign, callsign)
|
|
|> assign(:packets, stored)
|
|
|> assign(:live_packets, [])
|
|
|> assign(:all_packets, stored)
|
|
|> assign(:error, nil)
|
|
|> put_private(:packet_buffer, [])
|
|
|> put_private(:flush_timer, nil)
|
|
end
|
|
|
|
defp assign_invalid(socket, callsign) do
|
|
socket
|
|
|> assign(:callsign, callsign)
|
|
|> assign(:packets, [])
|
|
|> assign(:live_packets, [])
|
|
|> assign(:all_packets, [])
|
|
|> assign(:error, "Invalid callsign format")
|
|
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 updates assigns in one batch,
|
|
# 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)
|
|
|
|
{updated_stored, updated_live} =
|
|
packets
|
|
# Sanitize encoding first, then batch enrich with device info
|
|
# (enrich_packets_with_device_info batches device lookups internally)
|
|
|> Enum.map(&EncodingUtils.sanitize_packet/1)
|
|
|> SharedPacketHandler.enrich_packets_with_device_info()
|
|
# Add canonical device identifier per-packet
|
|
|> Enum.map(&enrich_packet_with_device_identifier/1)
|
|
# Accumulate through the packet list update logic
|
|
|> Enum.reduce({socket.assigns.packets, socket.assigns.live_packets}, fn enriched, {stored, live} ->
|
|
update_packet_lists(stored, live, enriched)
|
|
end)
|
|
|
|
all_packets = get_all_packets_list(updated_stored, updated_live)
|
|
|
|
{:noreply,
|
|
assign(socket,
|
|
packets: updated_stored,
|
|
live_packets: updated_live,
|
|
all_packets: all_packets
|
|
)}
|
|
end
|
|
|
|
def handle_info(_message, socket), do: {:noreply, socket}
|
|
|
|
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 enrich_packet_with_device_identifier(sanitized_payload) do
|
|
device_identifier =
|
|
Map.get(sanitized_payload, :device_identifier) ||
|
|
Map.get(sanitized_payload, "device_identifier") ||
|
|
DeviceParser.extract_device_identifier(sanitized_payload)
|
|
|
|
canonical_identifier = resolve_canonical_identifier(device_identifier)
|
|
Map.put(sanitized_payload, :device_identifier, canonical_identifier)
|
|
end
|
|
|
|
defp resolve_canonical_identifier(identifier) when is_binary(identifier) do
|
|
case Aprsme.DeviceIdentification.lookup_device_by_identifier(identifier) do
|
|
%{identifier: canonical} -> canonical
|
|
nil -> identifier
|
|
end
|
|
end
|
|
|
|
defp resolve_canonical_identifier(identifier), do: identifier
|
|
|
|
# Get recent packets for this callsign from the database (all packets, not just position)
|
|
# Returns the latest packets regardless of age, filtered by callsign
|
|
defp get_stored_packets(callsign, limit) do
|
|
# Create query for all packets (not just position packets) for this callsign
|
|
query =
|
|
from p in Packet,
|
|
order_by: [desc: p.received_at],
|
|
limit: ^limit
|
|
|
|
# Apply callsign filter using sender field for exact matching
|
|
# Use functional index on upper(sender) for performance
|
|
normalized_callsign = String.upcase(String.trim(callsign))
|
|
filtered_query = from p in query, where: fragment("upper(?)", p.sender) == ^normalized_callsign
|
|
|
|
filtered_query
|
|
|> Repo.all()
|
|
|> Enum.map(&EncodingUtils.sanitize_packet/1)
|
|
|> SharedPacketHandler.enrich_packets_with_device_info()
|
|
rescue
|
|
error ->
|
|
require Logger
|
|
|
|
Logger.error("Failed to fetch stored packets for callsign #{callsign}: #{inspect(error)}")
|
|
[]
|
|
end
|
|
|
|
# Helper to get all packets (stored + live) in chronological order
|
|
# Combines stored and live packets, sorts by timestamp (newest first), and limits to 100
|
|
defp get_all_packets_list(stored, live) do
|
|
# Combine and sort by received_at timestamp (newest first)
|
|
(live ++ stored)
|
|
|> Enum.sort_by(&get_timestamp_microseconds/1, :desc)
|
|
# Ensure we never exceed 100 total
|
|
|> Enum.take(100)
|
|
end
|
|
|
|
defp get_timestamp_microseconds(%DateTime{} = dt), do: DateTime.to_unix(dt, :microsecond)
|
|
|
|
defp get_timestamp_microseconds(dt) when is_binary(dt) do
|
|
case DateTime.from_iso8601(dt) do
|
|
{:ok, parsed_dt, _} -> DateTime.to_unix(parsed_dt, :microsecond)
|
|
_ -> 0
|
|
end
|
|
end
|
|
|
|
defp get_timestamp_microseconds(_), do: 0
|
|
|
|
# Helper to update stored and live packet lists, keeping total <= 100.
|
|
# Dispatches on capacity: at-cap with no stored, at-cap with stored, under-cap.
|
|
defp update_packet_lists(current_stored, current_live, sanitized_payload) do
|
|
total = length(current_live) + length(current_stored)
|
|
do_update(total, current_stored, current_live, sanitized_payload)
|
|
end
|
|
|
|
defp do_update(total, [], current_live, payload) when total >= 100, do: {[], [payload | Enum.drop(current_live, -1)]}
|
|
|
|
defp do_update(total, current_stored, current_live, payload) when total >= 100,
|
|
do: {Enum.drop(current_stored, -1), [payload | current_live]}
|
|
|
|
defp do_update(_total, current_stored, current_live, payload), do: {current_stored, [payload | current_live]}
|
|
end
|