aprs.me/lib/aprsme_web/live/info_live/show.html.heex
Graham McIntire b45993a8be
Fix APRS object display and comment encoding issues
- Strip Mic-E telemetry from comments before database storage
- Show sender callsign on map labels instead of object names for clarity
- Object names still used for grouping to maintain trail organization
- Add comprehensive tests for new encoding utilities

Fixes display issues where weather balloon "X3234025" was shown
instead of station "WA0YMH", and Mic-E telemetry bytes like "!w>`!"
were appearing in comments.
2026-02-20 18:06:32 -06:00

738 lines
36 KiB
Text

<!-- Leaflet is already loaded via vendor bundles in the layout -->
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Page header -->
<div class="bg-white shadow-xs ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-5 md:flex md:items-center md:justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center">
<h1 class="text-xl font-bold leading-7 sm:truncate sm:text-2xl">
{gettext("APRS Station")}
</h1>
<div class="ml-3 flex items-center space-x-3">
<%= if @packet do %>
<% {symbol_table, symbol_code} = AprsmeWeb.AprsSymbol.extract_from_packet(@packet) %>
<% display_symbol =
if symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/),
do: symbol_table,
else:
"#{AprsmeWeb.AprsSymbol.normalize_symbol_table(symbol_table)}#{AprsmeWeb.AprsSymbol.normalize_symbol_code(symbol_code)}" %>
<div title={display_symbol}>
{render_symbol_html(@packet)}
</div>
<% end %>
<span class="inline-flex items-center rounded-md bg-indigo-50 px-2.5 py-1 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-700/10 dark:bg-indigo-400/10 dark:text-indigo-400 dark:ring-indigo-400/30">
{@callsign}
</span>
</div>
</div>
</div>
<div class="mt-3 flex md:ml-4 md:mt-0">
<div class="flex space-x-2">
<.link
navigate={~p"/packets/#{@callsign}"}
class="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
{gettext("View packets")}
</.link>
<%= if @has_weather_packets do %>
<.link
navigate={~p"/weather/#{@callsign}"}
class="rounded-md bg-indigo-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{gettext("Weather charts")}
</.link>
<% end %>
</div>
</div>
</div>
</div>
</div>
<div class="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4">
<%= if @packet do %>
<!-- Station details -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 mb-6 lg:grid-rows-1">
<!-- Position information -->
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10 h-full">
<div class="px-6 py-6">
<div class="flex items-center mb-3">
<div class="flex-shrink-0">
<svg
class="h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
</svg>
</div>
<div class="ml-2">
<h3 class="text-sm font-medium">{gettext("Position Information")}</h3>
</div>
</div>
<dl class="grid grid-cols-2 gap-3">
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Location")}</dt>
<dd class="mt-1 text-sm font-semibold">
{@packet.lat || ""}, {@packet.lon || ""}
</dd>
</div>
<%= if @packet.lat && @packet.lon do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Grid Square")}</dt>
<dd class="mt-1 text-sm font-semibold">
{Aprsme.Maidenhead.encode(@packet.lon, @packet.lat) || "-"}
</dd>
</div>
<% end %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Last Activity")}</dt>
<dd class="mt-1 text-sm">
<%= if @packet.received_at do %>
<div
class="font-semibold"
phx-hook="TimeAgoHook"
id="last-position-time"
data-timestamp={DateTime.to_iso8601(@packet.received_at)}
>
{AprsmeWeb.TimeHelpers.time_ago_in_words(@packet.received_at)}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">
{Calendar.strftime(@packet.received_at, "%Y-%m-%d %H:%M:%S UTC")}
</div>
<% else %>
<span class="text-gray-500 dark:text-gray-400">{gettext("Unknown")}</span>
<% end %>
</dd>
</div>
<%= if @packet.altitude do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Altitude")}</dt>
<dd class="mt-1 text-sm font-semibold">{@packet.altitude} ft</dd>
</div>
<% end %>
<%= if @packet.course do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Course")}</dt>
<dd class="mt-1 text-sm font-semibold">{@packet.course}°</dd>
</div>
<% end %>
<%= if @packet.speed do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Speed")}</dt>
<dd class="mt-1 text-sm font-semibold">{@packet.speed} MPH</dd>
</div>
<% end %>
<%= if @packet.comment do %>
<div class="col-span-2">
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Comment")}</dt>
<dd class="mt-1 text-sm">
<%= if is_binary(@packet.comment) do %>
{Aprsme.EncodingUtils.sanitize_comment(@packet.comment)}
<% else %>
{@packet.comment}
<% end %>
</dd>
</div>
<% end %>
<%= if (@packet.data || %{})["phg_power"] || (@packet.data || %{})["phg_height"] || (@packet.data || %{})["phg_gain"] do %>
<div class="col-span-2">
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
{gettext("PHG (Power-Height-Gain)")}
</dt>
<dd class="grid grid-cols-2 gap-2 text-sm">
<%= if (@packet.data || %{})["phg_power"] do %>
<div>
<span class="text-gray-500 dark:text-gray-400">{gettext("Power:")}</span>
<span class="font-semibold">{(@packet.data || %{})["phg_power"]} W</span>
</div>
<% end %>
<%= if (@packet.data || %{})["phg_height"] do %>
<div>
<span class="text-gray-500 dark:text-gray-400">{gettext("HAAT:")}</span>
<span class="font-semibold">{(@packet.data || %{})["phg_height"]} ft</span>
</div>
<% end %>
<%= if (@packet.data || %{})["phg_gain"] do %>
<div>
<span class="text-gray-500 dark:text-gray-400">{gettext("Gain:")}</span>
<span class="font-semibold">{(@packet.data || %{})["phg_gain"]} dBi</span>
</div>
<% end %>
<%= if (@packet.data || %{})["phg_directivity"] do %>
<div>
<span class="text-gray-500 dark:text-gray-400">{gettext("Dir:")}</span>
<span class="font-semibold">
<%= if (@packet.data || %{})["phg_directivity"] == 0 or (@packet.data || %{})["phg_directivity"] == 360 do %>
{gettext("Omni")}
<% else %>
{(@packet.data || %{})["phg_directivity"]}°
<% end %>
</span>
</div>
<% end %>
</dd>
</div>
<% end %>
</dl>
</div>
</div>
<!-- Map showing station location -->
<%= if @packet && @packet.lat && @packet.lon do %>
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10 h-full">
<% {symbol_table, symbol_code} = AprsmeWeb.AprsSymbol.extract_from_packet(@packet) %>
<% symbol_html = AprsmeWeb.AprsSymbol.render_marker_html(symbol_table, symbol_code, @callsign, 32) %>
<.info_map
id="station-location-map"
lat={Aprsme.EncodingUtils.to_float(@packet.lat)}
lon={Aprsme.EncodingUtils.to_float(@packet.lon)}
callsign={@callsign}
symbol_html={symbol_html}
height="100%"
zoom={12}
/>
</div>
<% end %>
<!-- Row for Device Information and Other SSIDs -->
<div class="lg:col-span-2 grid grid-cols-1 gap-4 lg:grid-cols-2">
<!-- Device information -->
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10">
<div class="px-6 py-6">
<div class="flex items-center mb-3">
<div class="flex-shrink-0">
<svg
class="h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0020.75 3H3.75A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
/>
</svg>
</div>
<div class="ml-2">
<h3 class="text-sm font-medium">{gettext("Device Information")}</h3>
</div>
</div>
<dl class="grid grid-cols-2 gap-3">
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Device")}</dt>
<dd class="mt-1 text-sm font-semibold">
<%= if @packet.device_model || @packet.device_vendor do %>
{[
@packet.device_model,
@packet.device_vendor,
@packet.device_contact
]
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")}
<%= if @packet.device_class do %>
({@packet.device_class})
<% end %>
<% else %>
<span class="text-gray-500 dark:text-gray-400 font-mono">
{@packet.device_identifier || gettext("Unknown")}
</span>
<% end %>
</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Path")}</dt>
<dd class="mt-1 text-sm font-semibold">
<% path_elements = parse_path_with_links(@packet.path) %>
<%= for {{type, content, display}, index} <- Enum.with_index(path_elements) do %>
<%= case type do %>
<% :link -> %>
<.link
navigate={~p"/info/#{content}"}
class="font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
>
{display}
</.link>
<% :text -> %>
{content}
<% end %>
<%= if index < length(path_elements) - 1 do %>
<span class="text-gray-400 dark:text-gray-500">,</span>
<% end %>
<% end %>
</dd>
</div>
<%= if @packet.path && @packet.path != "" do %>
<div class="col-span-2">
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Decoded Path")}</dt>
<dd class="mt-1 text-sm">
{decode_aprs_path(@packet.path)}
</dd>
</div>
<% end %>
<div class="col-span-2">
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Raw Packet")}</dt>
<dd class="mt-1 text-xs font-mono break-all">
<%= if is_binary(@packet.raw_packet) do %>
{Aprsme.EncodingUtils.sanitize_string(@packet.raw_packet)}
<% else %>
{@packet.raw_packet || ""}
<% end %>
</dd>
</div>
</dl>
</div>
</div>
<!-- Other SSIDs -->
<%= if length(@other_ssids) > 0 do %>
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10">
<div class="px-6 py-6">
<div class="flex items-center mb-3">
<div class="flex-shrink-0">
<svg
class="h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
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>
</div>
<div class="ml-2">
<h3 class="text-sm font-medium">{gettext("Other SSIDs")}</h3>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10 text-sm">
<thead>
<tr>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Callsign")}
</th>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Distance & Direction")}
</th>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Last Heard")}
</th>
</tr>
</thead>
<tbody>
<%= for ssid_info <- @other_ssids do %>
<tr>
<td class="font-medium py-2">
<div class="flex items-center space-x-2">
<%= if ssid_info.packet do %>
<% {symbol_table, symbol_code} =
AprsmeWeb.AprsSymbol.extract_from_packet(ssid_info.packet) %>
<% display_symbol =
if symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/),
do: symbol_table,
else:
"#{AprsmeWeb.AprsSymbol.normalize_symbol_table(symbol_table)}#{AprsmeWeb.AprsSymbol.normalize_symbol_code(symbol_code)}" %>
<div class="flex-shrink-0 h-8" title={display_symbol}>
{render_symbol_html(ssid_info.packet)}
</div>
<% end %>
<.link
navigate={~p"/info/#{ssid_info.callsign}"}
class="font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
>
{ssid_info.callsign}
</.link>
</div>
</td>
<td class="text-gray-500 dark:text-gray-400">
<%= if ssid_info.packet && ssid_info.packet.lat && ssid_info.packet.lon && @packet && @packet.lat && @packet.lon do %>
<% dist =
haversine(
@packet.lat,
@packet.lon,
ssid_info.packet.lat,
ssid_info.packet.lon
) %>
<% course =
calculate_course(
@packet.lat,
@packet.lon,
ssid_info.packet.lat,
ssid_info.packet.lon
) %>
<% locale = Map.get(assigns, :locale, "en") %>
{format_distance(dist, locale)} @ {Float.round(course, 0)}°
<% else %>
-
<% end %>
</td>
<td class="text-gray-500 dark:text-gray-400">
<%= if ssid_info.received_at do %>
<span
class="text-sm"
phx-hook="TimeAgoHook"
id={"ssid-time-#{ssid_info.ssid}"}
data-timestamp={DateTime.to_iso8601(ssid_info.received_at)}
>
{AprsmeWeb.TimeHelpers.time_ago_in_words(ssid_info.received_at)}
</span>
<% else %>
<span class="text-sm text-gray-500 dark:text-gray-400">{gettext("Unknown")}</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<% else %>
<!-- Empty placeholder to maintain grid layout -->
<div></div>
<% end %>
</div>
</div>
<!-- Neighboring stations -->
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10">
<div class="px-6 py-6">
<div class="flex items-center mb-3">
<div class="flex-shrink-0">
<svg
class="h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
</div>
<div class="ml-2">
<h3 class="text-sm font-medium">{gettext("Stations Near Current Position")}</h3>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead>
<tr>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Callsign")}
</th>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Distance & Course")}
</th>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Last Heard")}
</th>
</tr>
</thead>
<tbody>
<%= for neighbor <- @neighbors do %>
<tr>
<td class="font-medium">
<div class="flex items-center space-x-2">
<%= if neighbor.packet do %>
<% {symbol_table, symbol_code} =
AprsmeWeb.AprsSymbol.extract_from_packet(neighbor.packet) %>
<% display_symbol =
if symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/),
do: symbol_table,
else:
"#{AprsmeWeb.AprsSymbol.normalize_symbol_table(symbol_table)}#{AprsmeWeb.AprsSymbol.normalize_symbol_code(symbol_code)}" %>
<div class="flex-shrink-0 h-8" title={display_symbol}>
{render_symbol_html(neighbor.packet)}
</div>
<% end %>
<.link
navigate={~p"/info/#{neighbor.callsign}"}
class="font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
>
{neighbor.callsign}
</.link>
</div>
</td>
<td class="text-gray-500 dark:text-gray-400">
<%= if neighbor.course do %>
{neighbor.distance || ""} @ {Float.round(neighbor.course, 0)}°
<% else %>
{neighbor.distance || ""}
<% end %>
</td>
<td class="text-gray-500 dark:text-gray-400">
<%= if neighbor.last_heard do %>
<div
class="font-semibold"
phx-hook="TimeAgoHook"
id={"neighbor-time-#{neighbor.callsign}"}
data-timestamp={neighbor.last_heard.timestamp}
>
{neighbor.last_heard.time_ago}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">
{neighbor.last_heard.formatted}
</div>
<% else %>
<span class="text-gray-500 dark:text-gray-400">{gettext("Unknown")}</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<!-- Stations that heard this callsign on RF -->
<%= if length(@heard_by_stations) > 0 do %>
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10 mt-6">
<div class="px-6 py-6">
<div class="flex items-center mb-3">
<div class="flex-shrink-0">
<svg
class="h-4 w-4 text-gray-400 dark:text-gray-500"
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="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead>
<tr>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Callsign")}
</th>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("First Heard")}
</th>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Last Heard")}
</th>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Packets")}
</th>
<th class="text-left 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="font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
>
{station.digipeater}
</.link>
</td>
<td class="text-gray-500 dark:text-gray-400">
<%= 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="text-gray-500 dark:text-gray-400">
<%= if station.last_heard do %>
<div class="font-semibold">
{AprsmeWeb.TimeHelpers.time_ago_in_words(station.last_heard)}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">
{Calendar.strftime(station.last_heard, "%Y-%m-%d %H:%M")}
</div>
<% else %>
-
<% end %>
</td>
<td>
{station.packet_count}
</td>
<td class="text-gray-500 dark:text-gray-400">
<%= if station.longest_distance do %>
{station.longest_distance}
<%= if station.longest_path_time do %>
<div class="text-xs text-gray-500 dark:text-gray-400 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="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10 mt-6">
<div class="px-6 py-6">
<div class="flex items-center mb-3">
<div class="flex-shrink-0">
<svg
class="h-4 w-4 text-gray-400 dark:text-gray-500"
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="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead>
<tr>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Callsign")}
</th>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("First Heard")}
</th>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Last Heard")}
</th>
<th class="text-left text-xs font-medium uppercase tracking-wider">
{gettext("Packets")}
</th>
<th class="text-left 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="font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
>
{station.station}
</.link>
</td>
<td class="text-gray-500 dark:text-gray-400">
<%= 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="text-gray-500 dark:text-gray-400">
<%= if station.last_heard do %>
<div class="font-semibold">
{AprsmeWeb.TimeHelpers.time_ago_in_words(station.last_heard)}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">
{Calendar.strftime(station.last_heard, "%Y-%m-%d %H:%M")}
</div>
<% else %>
-
<% end %>
</td>
<td>
{station.packet_count}
</td>
<td class="text-gray-500 dark:text-gray-400">
<%= if station.longest_distance do %>
{station.longest_distance}
<%= if station.longest_path_time do %>
<div class="text-xs text-gray-500 dark:text-gray-400 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
class="mx-auto h-8 w-8 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423L16.5 15.75l.394 1.183a2.25 2.25 0 001.423 1.423L19.5 18.75l-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
<h3 class="mt-2 text-sm font-semibold">{gettext("No data available")}</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{gettext("No recent packet data available for this callsign.")}
</p>
</div>
<% end %>
</div>
</div>