add more info on info page

This commit is contained in:
Graham McIntire 2025-07-13 10:12:59 -05:00
parent 184ca52a8c
commit 93b298ab9f
No known key found for this signature in database
2 changed files with 447 additions and 0 deletions

View file

@ -30,6 +30,9 @@ defmodule AprsmeWeb.InfoLive.Show do
has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign)
other_ssids = 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)
socket =
socket
|> assign(:callsign, normalized_callsign)
@ -38,6 +41,8 @@ defmodule AprsmeWeb.InfoLive.Show do
|> assign(:page_title, "APRS station #{normalized_callsign}")
|> assign(:has_weather_packets, has_weather_packets)
|> assign(:other_ssids, other_ssids)
|> assign(:heard_by_stations, heard_by_stations)
|> assign(:stations_heard_by, stations_heard_by)
{:ok, socket}
end
@ -62,6 +67,8 @@ defmodule AprsmeWeb.InfoLive.Show do
neighbors = get_neighbors(packet, socket.assigns.callsign, locale)
has_weather_packets = PacketUtils.has_weather_packets?(socket.assigns.callsign)
other_ssids = get_other_ssids(socket.assigns.callsign)
heard_by_stations = get_heard_by_stations(socket.assigns.callsign, locale)
stations_heard_by = get_stations_heard_by(socket.assigns.callsign, locale)
socket =
socket
@ -69,6 +76,8 @@ defmodule AprsmeWeb.InfoLive.Show do
|> assign(:neighbors, neighbors)
|> assign(:has_weather_packets, has_weather_packets)
|> assign(:other_ssids, other_ssids)
|> assign(:heard_by_stations, heard_by_stations)
|> assign(:stations_heard_by, stations_heard_by)
{:noreply, socket}
end
@ -275,6 +284,237 @@ defmodule AprsmeWeb.InfoLive.Show do
Callsign.extract_base(callsign)
end
defp get_heard_by_stations(callsign, locale) do
alias Aprsme.Repo
# Get packets from the last month where this callsign was heard on RF
one_month_ago = DateTime.add(DateTime.utc_now(), -30, :day)
# Query to find stations that heard this callsign directly
# In APRS, the first station with an asterisk (*) in the path is the one that heard the packet directly
query = """
WITH parsed_paths AS (
SELECT
id,
sender,
path,
received_at,
lat,
lon,
location,
-- Extract the first digipeater from the path
CASE
WHEN path ~ '[A-Z0-9]+-?[0-9]*\\*' THEN
substring(path from '([A-Z0-9]+-?[0-9]*)\\*')
ELSE NULL
END as first_digipeater
FROM packets
WHERE sender = $1
AND received_at >= $2
AND path IS NOT NULL
AND path != ''
AND path !~ '^TCPIP'
AND path !~ ',TCPIP'
),
digipeater_stats AS (
SELECT
first_digipeater as digipeater,
MIN(received_at) as first_heard,
MAX(received_at) as last_heard,
COUNT(*) as packet_count,
-- Find the packet with maximum distance for this digipeater
(SELECT pp2.id
FROM parsed_paths pp2
WHERE pp2.first_digipeater = pp.first_digipeater
AND pp2.lat IS NOT NULL
AND pp2.lon IS NOT NULL
ORDER BY
ST_Distance(
pp2.location::geography,
(SELECT location::geography
FROM packets
WHERE sender = pp2.first_digipeater
AND location IS NOT NULL
ORDER BY received_at DESC
LIMIT 1)
) DESC NULLS LAST
LIMIT 1
) as longest_path_packet_id
FROM parsed_paths pp
WHERE first_digipeater IS NOT NULL
GROUP BY first_digipeater
)
SELECT
ds.digipeater,
ds.first_heard,
ds.last_heard,
ds.packet_count,
p.received_at as longest_path_time,
CASE
WHEN p.location IS NOT NULL AND dig_loc.location IS NOT NULL THEN
ST_Distance(p.location::geography, dig_loc.location::geography) / 1000.0
ELSE NULL
END as longest_distance_km
FROM digipeater_stats ds
LEFT JOIN packets p ON p.id = ds.longest_path_packet_id
LEFT JOIN LATERAL (
SELECT location
FROM packets
WHERE sender = ds.digipeater
AND location IS NOT NULL
ORDER BY received_at DESC
LIMIT 1
) dig_loc ON true
ORDER BY ds.last_heard DESC
LIMIT 50
"""
case Repo.query(query, [callsign, one_month_ago]) do
{:ok, result} ->
result.rows
|> Enum.map(fn row ->
case row do
[digipeater, first_heard, last_heard, packet_count, longest_path_time, longest_distance_km] ->
%{
digipeater: digipeater || "",
first_heard: first_heard,
last_heard: last_heard,
packet_count: packet_count || 0,
longest_distance_km: longest_distance_km,
longest_distance: if(longest_distance_km, do: format_distance(longest_distance_km, locale)),
longest_path_time: longest_path_time
}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
{:error, error} ->
require Logger
Logger.error("Error in get_heard_by_stations: #{inspect(error)}")
[]
end
end
defp get_stations_heard_by(callsign, locale) do
alias Aprsme.Repo
# Get packets from the last month where this callsign heard other stations on RF
one_month_ago = DateTime.add(DateTime.utc_now(), -30, :day)
# Query to find stations that were heard directly by this callsign
# We look for packets where this callsign appears as the first digipeater with an asterisk
query = """
WITH parsed_paths AS (
SELECT
id,
sender,
path,
received_at,
lat,
lon,
location,
-- Extract the first digipeater from the path
CASE
WHEN path ~ '[A-Z0-9]+-?[0-9]*\\*' THEN
substring(path from '([A-Z0-9]+-?[0-9]*)\\*')
ELSE NULL
END as first_digipeater
FROM packets
WHERE path ~ ($1 || '\\*')
AND received_at >= $2
AND path IS NOT NULL
AND path != ''
AND path !~ '^TCPIP'
AND path !~ ',TCPIP'
),
station_stats AS (
SELECT
sender as station,
MIN(received_at) as first_heard,
MAX(received_at) as last_heard,
COUNT(*) as packet_count,
-- Find the packet with maximum distance for this station
(SELECT pp2.id
FROM parsed_paths pp2
WHERE pp2.sender = pp.sender
AND pp2.first_digipeater = $1
AND pp2.lat IS NOT NULL
AND pp2.lon IS NOT NULL
ORDER BY
ST_Distance(
pp2.location::geography,
(SELECT location::geography
FROM packets
WHERE sender = $1
AND location IS NOT NULL
ORDER BY received_at DESC
LIMIT 1)
) DESC NULLS LAST
LIMIT 1
) as longest_path_packet_id
FROM parsed_paths pp
WHERE first_digipeater = $1
GROUP BY sender
)
SELECT
ss.station,
ss.first_heard,
ss.last_heard,
ss.packet_count,
p.received_at as longest_path_time,
CASE
WHEN p.location IS NOT NULL AND dig_loc.location IS NOT NULL THEN
ST_Distance(p.location::geography, dig_loc.location::geography) / 1000.0
ELSE NULL
END as longest_distance_km
FROM station_stats ss
LEFT JOIN packets p ON p.id = ss.longest_path_packet_id
LEFT JOIN LATERAL (
SELECT location
FROM packets
WHERE sender = $1
AND location IS NOT NULL
ORDER BY received_at DESC
LIMIT 1
) dig_loc ON true
ORDER BY ss.last_heard DESC
LIMIT 50
"""
case Repo.query(query, [callsign, one_month_ago]) do
{:ok, result} ->
result.rows
|> Enum.map(fn row ->
case row do
[station, first_heard, last_heard, packet_count, longest_path_time, longest_distance_km] ->
%{
station: station || "",
first_heard: first_heard,
last_heard: last_heard,
packet_count: packet_count || 0,
longest_distance_km: longest_distance_km,
longest_distance: if(longest_distance_km, do: format_distance(longest_distance_km, locale)),
longest_path_time: longest_path_time
}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
{:error, error} ->
require Logger
Logger.error("Error in get_stations_heard_by: #{inspect(error)}")
[]
end
end
@doc """
Renders an APRS symbol style for use in templates.
"""

View file

@ -466,6 +466,213 @@
</div>
</div>
</div>
<!-- Stations that heard this callsign on RF -->
<%= if length(@heard_by_stations) > 0 do %>
<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<div class="flex items-center mb-3">
<div class="flex-shrink-0">
<svg
class="h-4 w-4 opacity-70"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
</div>
<div class="ml-2">
<h3 class="text-sm font-medium">
{gettext("Stations which heard %{callsign} directly on radio",
callsign: @callsign
)}
</h3>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th class="text-xs font-medium uppercase tracking-wider">
{gettext("Callsign")}
</th>
<th class="text-xs font-medium uppercase tracking-wider">
{gettext("First Heard")}
</th>
<th class="text-xs font-medium uppercase tracking-wider">
{gettext("Last Heard")}
</th>
<th class="text-xs font-medium uppercase tracking-wider">
{gettext("Packets")}
</th>
<th class="text-xs font-medium uppercase tracking-wider">
{gettext("Longest Path")}
</th>
</tr>
</thead>
<tbody>
<%= for station <- @heard_by_stations do %>
<tr>
<td class="font-medium">
<.link
navigate={~p"/info/#{station.digipeater}"}
class="link link-primary"
>
{station.digipeater}
</.link>
</td>
<td class="opacity-70">
<%= if station.first_heard do %>
<div class="text-xs font-mono">
{Calendar.strftime(station.first_heard, "%Y-%m-%d %H:%M")}
</div>
<% else %>
-
<% end %>
</td>
<td class="opacity-70">
<%= if station.last_heard do %>
<div class="font-semibold">
{AprsmeWeb.TimeHelpers.time_ago_in_words(station.last_heard)}
</div>
<div class="text-xs opacity-70 font-mono">
{Calendar.strftime(station.last_heard, "%Y-%m-%d %H:%M")}
</div>
<% else %>
-
<% end %>
</td>
<td>
{station.packet_count}
</td>
<td class="opacity-70">
<%= if station.longest_distance do %>
{station.longest_distance}
<%= if station.longest_path_time do %>
<div class="text-xs opacity-70 font-mono">
{Calendar.strftime(station.longest_path_time, "%Y-%m-%d %H:%M")}
</div>
<% end %>
<% else %>
-
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<% end %>
<!-- Stations heard directly by this callsign -->
<%= if length(@stations_heard_by) > 0 do %>
<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<div class="flex items-center mb-3">
<div class="flex-shrink-0">
<svg
class="h-4 w-4 opacity-70"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
</div>
<div class="ml-2">
<h3 class="text-sm font-medium">
{gettext("Stations heard directly by %{callsign}",
callsign: @callsign
)}
</h3>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th class="text-xs font-medium uppercase tracking-wider">
{gettext("Callsign")}
</th>
<th class="text-xs font-medium uppercase tracking-wider">
{gettext("First Heard")}
</th>
<th class="text-xs font-medium uppercase tracking-wider">
{gettext("Last Heard")}
</th>
<th class="text-xs font-medium uppercase tracking-wider">
{gettext("Packets")}
</th>
<th class="text-xs font-medium uppercase tracking-wider">
{gettext("Longest Path")}
</th>
</tr>
</thead>
<tbody>
<%= for station <- @stations_heard_by do %>
<tr>
<td class="font-medium">
<.link navigate={~p"/info/#{station.station}"} class="link link-primary">
{station.station}
</.link>
</td>
<td class="opacity-70">
<%= if station.first_heard do %>
<div class="text-xs font-mono">
{Calendar.strftime(station.first_heard, "%Y-%m-%d %H:%M")}
</div>
<% else %>
-
<% end %>
</td>
<td class="opacity-70">
<%= if station.last_heard do %>
<div class="font-semibold">
{AprsmeWeb.TimeHelpers.time_ago_in_words(station.last_heard)}
</div>
<div class="text-xs opacity-70 font-mono">
{Calendar.strftime(station.last_heard, "%Y-%m-%d %H:%M")}
</div>
<% else %>
-
<% end %>
</td>
<td>
{station.packet_count}
</td>
<td class="opacity-70">
<%= if station.longest_distance do %>
{station.longest_distance}
<%= if station.longest_path_time do %>
<div class="text-xs opacity-70 font-mono">
{Calendar.strftime(station.longest_path_time, "%Y-%m-%d %H:%M")}
</div>
<% end %>
<% else %>
-
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<% end %>
<% else %>
<div class="text-center py-8">
<svg