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:
parent
3b64c95fe9
commit
d803d070ac
6 changed files with 277 additions and 74 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue