diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex index 3c8b5fc..241d2e6 100644 --- a/lib/aprs_web/live/map_live/callsign_view.ex +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -595,7 +595,8 @@ defmodule AprsWeb.MapLive.CallsignView do
📡 {@callsign}
diff --git a/lib/aprs_web/live/packets_live/callsign_view.ex b/lib/aprs_web/live/packets_live/callsign_view.ex new file mode 100644 index 0000000..c315a7d --- /dev/null +++ b/lib/aprs_web/live/packets_live/callsign_view.ex @@ -0,0 +1,202 @@ +defmodule AprsWeb.PacketsLive.CallsignView do + @moduledoc """ + LiveView for displaying packets specific to a single callsign. + + Shows up to 100 packets total (stored + live) for the specified callsign. + Includes both stored packets from the database (last hour) and live incoming packets. + """ + use AprsWeb, :live_view + + import Ecto.Query + + alias Aprs.EncodingUtils + alias Aprs.Packet + alias Aprs.Repo + alias AprsWeb.Endpoint + + @impl true + def mount(%{"callsign" => callsign}, _session, socket) do + # Validate and normalize callsign + normalized_callsign = String.upcase(String.trim(callsign)) + + if valid_callsign?(normalized_callsign) do + # Subscribe to live packet updates if connected + if connected?(socket) do + Endpoint.subscribe("aprs_messages") + end + + # Get stored packets for this callsign (up to 100) + stored_packets = get_stored_packets(normalized_callsign, 100) + + socket = + socket + |> assign(:callsign, normalized_callsign) + |> assign(:packets, stored_packets) + |> assign(:live_packets, []) + |> assign(:all_packets, stored_packets) + |> assign(:error, nil) + + {:ok, socket} + else + socket = + socket + |> assign(:callsign, normalized_callsign) + |> assign(:packets, []) + |> assign(:live_packets, []) + |> assign(:all_packets, []) + |> assign(:error, "Invalid callsign format") + + {:ok, socket} + end + end + + @impl true + def handle_info(%{event: "packet", payload: payload}, socket) do + # Handle incoming live packets - only process if they match our callsign + # Only process packets for this specific callsign + if packet_matches_callsign?(payload, socket.assigns.callsign) do + # Sanitize the packet to prevent JSON encoding errors + sanitized_payload = EncodingUtils.sanitize_packet(payload) + + # Add to live packets and maintain combined limit of 100 + current_live = socket.assigns.live_packets + current_stored = socket.assigns.packets + + # Calculate how many total packets we have + total_count = length(current_live) + length(current_stored) + + # If we're at or over the limit, remove the oldest stored packet + {updated_stored, updated_live} = + if total_count >= 100 do + # Remove the oldest stored packet if we have any, otherwise remove oldest live + case current_stored do + [] -> + # No stored packets, remove oldest live packet + live_without_oldest = Enum.drop(current_live, -1) + {current_stored, [sanitized_payload | live_without_oldest]} + + _ -> + # Remove oldest stored packet + stored_without_oldest = Enum.drop(current_stored, -1) + {stored_without_oldest, [sanitized_payload | current_live]} + end + else + # Under limit, just add the new packet + {current_stored, [sanitized_payload | current_live]} + end + + # Calculate combined packets for display + all_packets = get_all_packets_list(updated_stored, updated_live) + + socket = + socket + |> assign(:packets, updated_stored) + |> assign(:live_packets, updated_live) + |> assign(:all_packets, all_packets) + + {:noreply, socket} + else + {:noreply, socket} + end + end + + @impl true + def handle_info(_message, socket) do + {:noreply, socket} + end + + # Private helper functions + + # Get recent packets for this callsign from the database (all packets, not just position) + # Returns packets from the last hour, filtered by callsign + defp get_stored_packets(callsign, limit) do + one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) + + # Create query for all packets (not just position packets) for this callsign + query = + from p in Packet, + where: p.received_at >= ^one_hour_ago, + order_by: [desc: p.received_at], + limit: ^limit + + # Apply callsign filter + filtered_query = + if String.contains?(callsign, "-") do + # Exact match for callsign with SSID + [base_call, ssid] = String.split(callsign, "-", parts: 2) + + from p in query, + where: + ilike(p.base_callsign, ^base_call) and + ((is_nil(p.ssid) and ^ssid == "0") or p.ssid == ^ssid) + else + # Match base callsign exactly, regardless of SSID + from p in query, where: ilike(p.base_callsign, ^callsign) + end + + filtered_query + |> Repo.all() + |> Enum.map(&EncodingUtils.sanitize_packet/1) + rescue + error -> + require Logger + + Logger.error("Failed to fetch stored packets for callsign #{callsign}: #{inspect(error)}") + [] + end + + defp packet_matches_callsign?(packet, target_callsign) do + # Check if packet sender or base_callsign matches the target callsign + # Supports both exact matches and SSID variants (e.g., "N0CALL" matches "N0CALL-1") + sender = packet.sender || "" + base_callsign = packet.base_callsign || "" + + # Convert to uppercase for case-insensitive comparison + sender_upper = String.upcase(sender) + base_upper = String.upcase(base_callsign) + target_upper = String.upcase(target_callsign) + + # Check exact match for sender or base_callsign + # Also check if the sender starts with the target callsign (for SSID variants) + sender_upper == target_upper || base_upper == target_upper || + String.starts_with?(sender_upper, target_upper <> "-") + 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( + fn packet -> + case packet.received_at do + %DateTime{} = dt -> + DateTime.to_unix(dt, :microsecond) + + dt when is_binary(dt) -> + case DateTime.from_iso8601(dt) do + {:ok, parsed_dt, _} -> DateTime.to_unix(parsed_dt, :microsecond) + _ -> 0 + end + + _ -> + 0 + end + end, + :desc + ) + # Ensure we never exceed 100 total + |> Enum.take(100) + end + + # Validates if the callsign format is reasonable + defp valid_callsign?(callsign) do + # Basic validation for amateur radio callsign format + # Should be 3-8 characters, can contain letters, numbers, and one hyphen for SSID + case String.trim(callsign) do + "" -> false + cs when byte_size(cs) < 3 or byte_size(cs) > 15 -> false + cs -> Regex.match?(~r/^[A-Z0-9]+(-[A-Z0-9]{1,2})?$/i, cs) + end + end +end diff --git a/lib/aprs_web/live/packets_live/callsign_view.html.heex b/lib/aprs_web/live/packets_live/callsign_view.html.heex new file mode 100644 index 0000000..01de175 --- /dev/null +++ b/lib/aprs_web/live/packets_live/callsign_view.html.heex @@ -0,0 +1,94 @@ +<.header> + Packets for {@callsign} + <:subtitle> + Showing up to 100 packets (stored and live) for callsign {@callsign} + + + +<%= if @error do %> +
+
+
+ + + +
+
+

Error

+
+ {@error} +
+
+
+
+<% else %> +
+ <.link + navigate={~p"/#{String.downcase(@callsign)}"} + class="text-blue-600 hover:text-blue-800 underline" + > + ← View {@callsign} on Map + +
+ + <.table id="callsign-packets" rows={@all_packets}> + <:col :let={packet} label="Time"> + + <%= case packet.received_at do %> + <% %DateTime{} = dt -> %> + {Calendar.strftime(dt, "%H:%M:%S")} + <% dt when is_binary(dt) -> %> + <%= case DateTime.from_iso8601(dt) do %> + <% {:ok, parsed_dt, _} -> %> + {Calendar.strftime(parsed_dt, "%H:%M:%S")} + <% _ -> %> + {dt} + <% end %> + <% _ -> %> + N/A + <% end %> + + + <:col :let={packet} label="Sender">{packet.sender} + <:col :let={packet} label="SSID">{packet.ssid} + <:col :let={packet} label="Data Type">{packet.data_type} + <:col :let={packet} label="Destination">{packet.destination} + <:col :let={packet} label="Information"> + + <%= if String.length(packet.information_field || "") > 50 do %> + + {String.slice(packet.information_field, 0, 50)}... + + <% else %> + {packet.information_field} + <% end %> + + + <:col :let={packet} label="Path"> + + {packet.path} + + + + + <%= if length(@all_packets) == 0 do %> +
+

No packets found for {@callsign}

+

+ Packets will appear here as they are received live or if there are stored packets for this callsign. +

+
+ <% end %> + +
+

+ Live packets: {length(@live_packets)} | + Stored packets: {length(@packets)} | + Total: {length(@all_packets)}/100 +

+
+<% end %> diff --git a/lib/aprs_web/live/packets_live/index.html.heex b/lib/aprs_web/live/packets_live/index.html.heex index 0b7cb15..688ecfe 100644 --- a/lib/aprs_web/live/packets_live/index.html.heex +++ b/lib/aprs_web/live/packets_live/index.html.heex @@ -3,9 +3,23 @@ <.table id="packets" rows={@packets}> - <:col :let={packet} label="sender">{packet.sender} + <:col :let={packet} label="sender"> + <.link + navigate={~p"/packets/#{packet.base_callsign}"} + class="text-blue-600 hover:text-blue-800 underline" + > + {packet.sender} + + <:col :let={packet} label="ssid">{packet.ssid} - <:col :let={packet} label="base_callsign">{packet.base_callsign} + <:col :let={packet} label="base_callsign"> + <.link + navigate={~p"/packets/#{packet.base_callsign}"} + class="text-blue-600 hover:text-blue-800 underline" + > + {packet.base_callsign} + + <:col :let={packet} label="data_type">{packet.data_type} <:col :let={packet} label="destination">{packet.destination} <:col :let={packet} label="information_field">{packet.information_field} diff --git a/lib/aprs_web/router.ex b/lib/aprs_web/router.ex index 12e9015..3b36dcf 100644 --- a/lib/aprs_web/router.ex +++ b/lib/aprs_web/router.ex @@ -33,6 +33,7 @@ defmodule AprsWeb.Router do live "/status", StatusLive.Index, :index live "/packets", PacketsLive.Index, :index + live "/packets/:callsign", PacketsLive.CallsignView, :index live "/:callsign", MapLive.CallsignView, :index end