- 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.
738 lines
36 KiB
Text
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>
|