Add other SSIDs list to map sidebar, fix context/web boundary

- Add Packets.get_other_ssids/1 to query other SSIDs for a base callsign
- Show other SSIDs in MapLive sidebar with track and info link buttons
- Clicking an SSID tracks it on the map (zooms, shows marker, updates URL)
- Refactor InfoLive.Show to use shared Packets.get_other_ssids/1
- Remove web-layer dependency from Packets context (no more
  AprsmeWeb.TimeHelpers calls from data layer)
- Return raw received_at DateTime instead of pre-formatted timestamp maps
- Format timestamps in the view layer where they belong
This commit is contained in:
Graham McIntire 2026-02-18 18:14:50 -06:00
parent 3b64c95fe9
commit d803d070ac
No known key found for this signature in database
6 changed files with 277 additions and 74 deletions

View file

@ -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
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

View file

@ -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

View file

@ -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

View file

@ -368,14 +368,14 @@
<% end %>
</td>
<td class="opacity-70">
<%= if ssid_info.last_heard && ssid_info.last_heard.timestamp do %>
<%= if ssid_info.received_at do %>
<span
class="text-sm"
phx-hook="TimeAgoHook"
id={"ssid-time-#{ssid_info.ssid}"}
data-timestamp={ssid_info.last_heard.timestamp}
data-timestamp={DateTime.to_iso8601(ssid_info.received_at)}
>
{ssid_info.last_heard.time_ago}
{AprsmeWeb.TimeHelpers.time_ago_in_words(ssid_info.received_at)}
</span>
<% else %>
<span class="text-sm opacity-70">{gettext("Unknown")}</span>

View file

@ -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
</form>
</div>
<!-- Other SSIDs -->
<%= if @tracked_callsign != "" and @other_ssids != [] do %>
<div class="space-y-3">
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 flex items-center space-x-2">
<svg class="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
<span>{gettext("Other SSIDs")}</span>
</label>
<div class="space-y-1">
<%= for ssid_info <- @other_ssids do %>
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 hover:border-indigo-300 dark:hover:border-indigo-600 transition-all duration-200 text-sm group">
<button
phx-click="track_callsign"
phx-value-callsign={ssid_info.callsign}
class="font-semibold text-indigo-600 dark:text-indigo-400 group-hover:text-indigo-800 dark:group-hover:text-indigo-300 cursor-pointer"
>
{ssid_info.callsign}
</button>
<div class="flex items-center space-x-2">
<%= if ssid_info.received_at do %>
<span class="text-xs text-slate-500 dark:text-slate-400">
{time_ago_in_words(ssid_info.received_at)}
</span>
<% 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")}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</.link>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<!-- Trail Duration -->
<div class="space-y-4">
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 flex items-center space-x-2">

View file

@ -744,6 +744,133 @@ defmodule Aprsme.PacketsTest do
end
end
describe "get_other_ssids/2" do
test "returns other SSIDs for a base callsign" do
now = DateTime.utc_now()
# Create packets for multiple SSIDs of the same base callsign
PacketsFixtures.packet_fixture(%{
sender: "AE5PL",
base_callsign: "AE5PL",
ssid: "0",
received_at: DateTime.add(now, -1800, :second),
symbol_table_id: "/",
symbol_code: "-"
})
PacketsFixtures.packet_fixture(%{
sender: "AE5PL-1",
base_callsign: "AE5PL",
ssid: "1",
received_at: DateTime.add(now, -900, :second),
symbol_table_id: "/",
symbol_code: ">"
})
PacketsFixtures.packet_fixture(%{
sender: "AE5PL-15",
base_callsign: "AE5PL",
ssid: "15",
received_at: DateTime.add(now, -300, :second),
symbol_table_id: "/",
symbol_code: "#"
})
# Query for SSIDs other than AE5PL (the base callsign with no SSID)
results = Packets.get_other_ssids("AE5PL")
callsigns = Enum.map(results, & &1.callsign)
assert "AE5PL-1" in callsigns
assert "AE5PL-15" in callsigns
refute "AE5PL" in callsigns
end
test "returns other SSIDs when querying from an SSID variant" do
now = DateTime.utc_now()
PacketsFixtures.packet_fixture(%{
sender: "W5ABC",
base_callsign: "W5ABC",
ssid: "0",
received_at: DateTime.add(now, -1800, :second)
})
PacketsFixtures.packet_fixture(%{
sender: "W5ABC-9",
base_callsign: "W5ABC",
ssid: "9",
received_at: DateTime.add(now, -900, :second)
})
# Query from the -9 SSID should show the base callsign
results = Packets.get_other_ssids("W5ABC-9")
callsigns = Enum.map(results, & &1.callsign)
assert "W5ABC" in callsigns
refute "W5ABC-9" in callsigns
end
test "returns empty list when no other SSIDs exist" do
PacketsFixtures.packet_fixture(%{
sender: "LONELY",
base_callsign: "LONELY",
ssid: "0"
})
results = Packets.get_other_ssids("LONELY")
assert results == []
end
test "only returns SSIDs with recent packets" do
now = DateTime.utc_now()
# Create a recent packet
PacketsFixtures.packet_fixture(%{
sender: "RECENT-1",
base_callsign: "RECENT",
ssid: "1",
received_at: DateTime.add(now, -300, :second)
})
# Create an old packet (2 hours ago, outside 1-hour window)
PacketsFixtures.packet_fixture(%{
sender: "RECENT-2",
base_callsign: "RECENT",
ssid: "2",
received_at: DateTime.add(now, -7200, :second)
})
results = Packets.get_other_ssids("RECENT")
callsigns = Enum.map(results, & &1.callsign)
assert "RECENT-1" in callsigns
refute "RECENT-2" in callsigns
end
test "includes symbol information and received_at in results" do
now = DateTime.utc_now()
PacketsFixtures.packet_fixture(%{
sender: "SYM-1",
base_callsign: "SYM",
ssid: "1",
received_at: now,
symbol_table_id: "/",
symbol_code: ">"
})
[result] = Packets.get_other_ssids("SYM")
assert result.callsign == "SYM-1"
assert result.ssid == "1"
assert result.packet.symbol_table_id == "/"
assert result.packet.symbol_code == ">"
# Returns raw received_at DateTime, not a formatted map
assert %DateTime{} = result.received_at
refute Map.has_key?(result, :last_heard)
end
end
describe "get_last_hour_packets/0" do
test "returns packets from last hour" do
now = DateTime.utc_now()