add callsign-specific packets page
This commit is contained in:
parent
2d83e64256
commit
f7adc2ea14
5 changed files with 315 additions and 3 deletions
|
|
@ -595,7 +595,8 @@ defmodule AprsWeb.MapLive.CallsignView do
|
|||
<div class="callsign-title">📡 {@callsign}</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">← Back to Map</a>
|
||||
<a href="/packets" class="nav-link">Packets</a>
|
||||
<a href="/packets" class="nav-link">All Packets</a>
|
||||
<a href={"/packets/#{String.downcase(@callsign)}"} class="nav-link">{@callsign} Packets</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
202
lib/aprs_web/live/packets_live/callsign_view.ex
Normal file
202
lib/aprs_web/live/packets_live/callsign_view.ex
Normal file
|
|
@ -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
|
||||
94
lib/aprs_web/live/packets_live/callsign_view.html.heex
Normal file
94
lib/aprs_web/live/packets_live/callsign_view.html.heex
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<.header>
|
||||
Packets for {@callsign}
|
||||
<:subtitle>
|
||||
Showing up to 100 packets (stored and live) for callsign {@callsign}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<%= if @error do %>
|
||||
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">Error</h3>
|
||||
<div class="mt-1 text-sm text-red-700">
|
||||
{@error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mb-4">
|
||||
<.link
|
||||
navigate={~p"/#{String.downcase(@callsign)}"}
|
||||
class="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
← View {@callsign} on Map
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<.table id="callsign-packets" rows={@all_packets}>
|
||||
<:col :let={packet} label="Time">
|
||||
<span class="text-sm text-gray-600">
|
||||
<%= 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 %>
|
||||
</span>
|
||||
</:col>
|
||||
<:col :let={packet} label="Sender">{packet.sender}</:col>
|
||||
<:col :let={packet} label="SSID">{packet.ssid}</:col>
|
||||
<:col :let={packet} label="Data Type">{packet.data_type}</:col>
|
||||
<:col :let={packet} label="Destination">{packet.destination}</:col>
|
||||
<:col :let={packet} label="Information">
|
||||
<span class="text-sm">
|
||||
<%= if String.length(packet.information_field || "") > 50 do %>
|
||||
<span title={packet.information_field}>
|
||||
{String.slice(packet.information_field, 0, 50)}...
|
||||
</span>
|
||||
<% else %>
|
||||
{packet.information_field}
|
||||
<% end %>
|
||||
</span>
|
||||
</:col>
|
||||
<:col :let={packet} label="Path">
|
||||
<span class="text-xs text-gray-500">
|
||||
{packet.path}
|
||||
</span>
|
||||
</:col>
|
||||
</.table>
|
||||
|
||||
<%= if length(@all_packets) == 0 do %>
|
||||
<div class="text-center py-8">
|
||||
<p class="text-gray-500">No packets found for {@callsign}</p>
|
||||
<p class="text-sm text-gray-400 mt-2">
|
||||
Packets will appear here as they are received live or if there are stored packets for this callsign.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>
|
||||
Live packets: {length(@live_packets)} |
|
||||
Stored packets: {length(@packets)} |
|
||||
Total: {length(@all_packets)}/100
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
@ -3,9 +3,23 @@
|
|||
</.header>
|
||||
|
||||
<.table id="packets" rows={@packets}>
|
||||
<:col :let={packet} label="sender">{packet.sender}</:col>
|
||||
<:col :let={packet} label="sender">
|
||||
<.link
|
||||
navigate={~p"/packets/#{packet.base_callsign}"}
|
||||
class="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
{packet.sender}
|
||||
</.link>
|
||||
</:col>
|
||||
<:col :let={packet} label="ssid">{packet.ssid}</:col>
|
||||
<:col :let={packet} label="base_callsign">{packet.base_callsign}</:col>
|
||||
<: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}
|
||||
</.link>
|
||||
</:col>
|
||||
<:col :let={packet} label="data_type">{packet.data_type}</:col>
|
||||
<:col :let={packet} label="destination">{packet.destination}</:col>
|
||||
<:col :let={packet} label="information_field">{packet.information_field}</:col>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue