diff --git a/.sobelow-skips b/.sobelow-skips
index 97678f3..24cd4fb 100644
--- a/.sobelow-skips
+++ b/.sobelow-skips
@@ -25,4 +25,8 @@ SQL.Query: SQL injection,lib/aprsme/packets.ex:664,17532E
XSS.Raw: XSS,lib/aprsme_web/components/core_components.ex:48,1E00DD8
SQL.Query: SQL injection,lib/aprsme_web/live/info_live/show.ex:603,52B452D
XSS.Raw: XSS,lib/aprsme_web/live/info_live/show.ex:661,5C70BB5
-XSS.Raw: XSS,lib/aprsme_web/live/info_live/show.ex:643,692A7EC
\ No newline at end of file
+XSS.Raw: XSS,lib/aprsme_web/live/info_live/show.ex:643,692A7EC
+XSS.Raw: XSS,lib/aprsme_web/live/info_live/show.ex:604,12E0C65
+SQL.Query: SQL injection,lib/aprsme_web/live/info_live/show.ex:448,1662A49
+SQL.Query: SQL injection,lib/aprsme_web/live/info_live/show.ex:546,37D03BD
+XSS.Raw: XSS,lib/aprsme_web/live/info_live/show.ex:586,509A015
\ No newline at end of file
diff --git a/lib/aprsme/packets.ex b/lib/aprsme/packets.ex
index 0f16e74..6a53170 100644
--- a/lib/aprsme/packets.ex
+++ b/lib/aprsme/packets.ex
@@ -859,6 +859,54 @@ defmodule Aprsme.Packets do
PreparedQueries.has_weather_packets?(callsign)
end
+ @doc """
+ Gets other SSIDs for a given callsign's base callsign.
+ Returns a list of maps with callsign, ssid, received_at, and packet info.
+ Only returns SSIDs active within the last hour.
+ """
+ @spec get_other_ssids(String.t()) :: list(map())
+ def get_other_ssids(callsign) when is_binary(callsign) do
+ base_callsign = Aprsme.Callsign.extract_base(callsign)
+ one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
+
+ query =
+ from p in Packet,
+ where: p.base_callsign == ^base_callsign,
+ where: p.received_at >= ^one_hour_ago,
+ where: p.sender != ^callsign,
+ distinct: p.sender,
+ order_by: [desc: p.received_at],
+ limit: 10,
+ select: %{
+ sender: p.sender,
+ ssid: p.ssid,
+ received_at: p.received_at,
+ id: p.id,
+ symbol_table_id: p.symbol_table_id,
+ symbol_code: p.symbol_code
+ }
+
+ query
+ |> Repo.all()
+ |> Enum.map(fn row ->
+ packet = %Packet{
+ id: row.id,
+ sender: row.sender,
+ ssid: row.ssid,
+ received_at: row.received_at,
+ symbol_table_id: row.symbol_table_id,
+ symbol_code: row.symbol_code
+ }
+
+ %{
+ callsign: row.sender,
+ ssid: row.ssid,
+ received_at: row.received_at,
+ packet: packet
+ }
+ end)
+ end
+
defp get_latest_weather_in_window(callsign, hours) do
import Ecto.Query
diff --git a/lib/aprsme_web/live/info_live/show.ex b/lib/aprsme_web/live/info_live/show.ex
index 6319c76..47297a4 100644
--- a/lib/aprsme_web/live/info_live/show.ex
+++ b/lib/aprsme_web/live/info_live/show.ex
@@ -12,7 +12,6 @@ defmodule AprsmeWeb.InfoLive.Show do
alias AprsmeWeb.AprsSymbol
alias AprsmeWeb.Live.SharedPacketHandler
alias AprsmeWeb.MapLive.PacketUtils
- alias AprsmeWeb.TimeUtils
@neighbor_limit 10
@@ -74,7 +73,7 @@ defmodule AprsmeWeb.InfoLive.Show do
locale = Map.get(socket.assigns, :locale, "en")
neighbors = get_neighbors(packet, normalized_callsign, locale)
has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign)
- other_ssids = get_other_ssids(normalized_callsign)
+ other_ssids = Packets.get_other_ssids(normalized_callsign)
heard_by_stations = get_heard_by_stations(normalized_callsign, locale)
stations_heard_by = get_stations_heard_by(normalized_callsign, locale)
@@ -361,62 +360,6 @@ defmodule AprsmeWeb.InfoLive.Show do
if bearing < 0, do: bearing + 360, else: bearing
end
- defp get_other_ssids(callsign) do
- alias Aprsme.Packet
- alias Aprsme.Repo
- # Extract base callsign from the full callsign (remove SSID if present)
- base_callsign = extract_base_callsign(callsign)
-
- # Get recent packets for the base callsign to find other SSIDs
- one_hour_ago = TimeUtils.one_hour_ago()
-
- # Use a window function to get the most recent packet per sender
- # This is much more efficient than fetching 100 packets and filtering in Elixir
- query = """
- WITH recent_ssids AS (
- SELECT DISTINCT ON (sender)
- sender, ssid, received_at, id, symbol_table_id, symbol_code
- FROM packets
- WHERE base_callsign = $1
- AND received_at >= $2
- AND sender != $3
- ORDER BY sender, received_at DESC
- )
- SELECT * FROM recent_ssids
- ORDER BY received_at DESC
- LIMIT 10
- """
-
- case Repo.query(query, [base_callsign, one_hour_ago, callsign]) do
- {:ok, result} ->
- Enum.map(result.rows, fn [sender, ssid, received_at, id, symbol_table_id, symbol_code] ->
- # Create a minimal packet struct for display
- packet = %Packet{
- id: id,
- sender: sender,
- ssid: ssid,
- received_at: received_at,
- symbol_table_id: symbol_table_id,
- symbol_code: symbol_code
- }
-
- %{
- callsign: sender,
- ssid: ssid,
- last_heard: format_timestamp_for_display(packet),
- packet: packet
- }
- end)
-
- {:error, _} ->
- []
- end
- end
-
- defp extract_base_callsign(callsign) do
- Callsign.extract_base(callsign)
- end
-
defp get_heard_by_stations(callsign, locale) do
alias Aprsme.Repo
diff --git a/lib/aprsme_web/live/info_live/show.html.heex b/lib/aprsme_web/live/info_live/show.html.heex
index 8abd466..89910ea 100644
--- a/lib/aprsme_web/live/info_live/show.html.heex
+++ b/lib/aprsme_web/live/info_live/show.html.heex
@@ -368,14 +368,14 @@
<% end %>
- <%= if ssid_info.last_heard && ssid_info.last_heard.timestamp do %>
+ <%= if ssid_info.received_at do %>
- {ssid_info.last_heard.time_ago}
+ {AprsmeWeb.TimeHelpers.time_ago_in_words(ssid_info.received_at)}
<% else %>
{gettext("Unknown")}
diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex
index 10ff42b..4ccb978 100644
--- a/lib/aprsme_web/live/map_live/index.ex
+++ b/lib/aprsme_web/live/map_live/index.ex
@@ -176,20 +176,22 @@ defmodule AprsmeWeb.MapLive.Index do
historical_hours = Map.get(socket.assigns, :historical_hours, "1")
packet_age_threshold = Map.get(socket.assigns, :packet_age_threshold, one_hour_ago)
- # If tracking a specific callsign, fetch their latest packet
- tracked_callsign_latest_packet =
+ # If tracking a specific callsign, fetch their latest packet and other SSIDs
+ {tracked_callsign_latest_packet, other_ssids} =
if tracked_callsign == "" do
- nil
+ {nil, []}
else
try do
- Packets.get_latest_packet_for_callsign(tracked_callsign)
+ packet = Packets.get_latest_packet_for_callsign(tracked_callsign)
+ ssids = Packets.get_other_ssids(tracked_callsign)
+ {packet, ssids}
rescue
# Handle database connection errors gracefully (especially in tests)
DBConnection.OwnershipError ->
- nil
+ {nil, []}
_ ->
- nil
+ {nil, []}
end
end
@@ -206,6 +208,7 @@ defmodule AprsmeWeb.MapLive.Index do
overlay_callsign: "",
tracked_callsign: tracked_callsign,
tracked_callsign_latest_packet: tracked_callsign_latest_packet,
+ other_ssids: other_ssids,
trail_duration: trail_duration,
historical_hours: historical_hours,
packet_age_threshold: packet_age_threshold,
@@ -461,13 +464,40 @@ defmodule AprsmeWeb.MapLive.Index do
if normalized_callsign == "" do
# Clear tracking
socket
- |> assign(tracked_callsign: "")
+ |> assign(tracked_callsign: "", other_ssids: [])
|> update_url_with_current_state()
else
- # Set tracking and navigate to callsign URL
- socket
- |> assign(tracked_callsign: normalized_callsign)
- |> push_patch(to: "/#{normalized_callsign}")
+ # Set tracking, fetch latest packet, zoom to location, and show marker
+ other_ssids = Packets.get_other_ssids(normalized_callsign)
+ latest_packet = Packets.get_latest_packet_for_callsign(normalized_callsign)
+
+ socket =
+ assign(socket,
+ tracked_callsign: normalized_callsign,
+ other_ssids: other_ssids,
+ tracked_callsign_latest_packet: latest_packet
+ )
+
+ # Zoom to the callsign's location and display its marker
+ socket =
+ if latest_packet && latest_packet.lat && latest_packet.lon do
+ lat = Aprsme.EncodingUtils.to_float(latest_packet.lat) || 0.0
+ lon = Aprsme.EncodingUtils.to_float(latest_packet.lon) || 0.0
+
+ packet_data = DataBuilder.build_packet_data(latest_packet)
+
+ socket
+ |> push_event("zoom_to_location", %{lat: lat, lng: lon, zoom: 12})
+ |> push_event("add_historical_packets_batch", %{
+ packets: [packet_data],
+ batch: 0,
+ is_final: false
+ })
+ else
+ socket
+ end
+
+ push_patch(socket, to: "/#{normalized_callsign}")
end
{:noreply, socket}
@@ -477,7 +507,7 @@ defmodule AprsmeWeb.MapLive.Index do
def handle_event("clear_tracking", _params, socket) do
socket =
socket
- |> assign(tracked_callsign: "", overlay_callsign: "")
+ |> assign(tracked_callsign: "", overlay_callsign: "", other_ssids: [])
|> update_url_with_current_state()
{:noreply, socket}
@@ -1489,6 +1519,57 @@ defmodule AprsmeWeb.MapLive.Index do
+
+ <%= if @tracked_callsign != "" and @other_ssids != [] do %>
+
+
+
+ <%= for ssid_info <- @other_ssids do %>
+
+
+
+ <%= if ssid_info.received_at do %>
+
+ {time_ago_in_words(ssid_info.received_at)}
+
+ <% end %>
+ <.link
+ navigate={~p"/info/#{ssid_info.callsign}"}
+ class="text-xs text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
+ title={gettext("Station info")}
+ >
+
+
+
+
+ <% end %>
+
+
+ <% end %>
+
|