aprs.me/lib/aprsme_web/live/status_live/index.ex
Graham McIntire b8a9b8a465
chore(dialyzer): enable stricter flags and fix 97 resulting findings
Turned on :error_handling, :underspecs, and :unmatched_returns in
mix.exs dialyzer config. The 97 warnings this surfaced were fixed in
place rather than suppressed:

- unmatched_return (79): explicit discard with `_ = ...` for
  fire-and-forget side effects (Process.cancel_timer, :ets.new,
  send/2), and pattern-matched `:ok = ...` for control-plane
  Phoenix.PubSub subscribe/unsubscribe/broadcast calls so a future
  return-shape change fails loud.

- contract_supertype (18): tightened @spec arg and return types on
  data_builder, historical_loader, url_params, packet_utils,
  encoding_utils, aprs_symbol, weather_controller, packet_replay to
  match each function's actual success typing.

No behavioural change. mix compile clean, 1008 tests pass, dialyzer
count is now 0.
2026-04-21 10:07:01 -05:00

518 lines
21 KiB
Elixir

defmodule AprsmeWeb.StatusLive.Index do
@moduledoc """
LiveView for displaying real-time APRS-IS connection status
"""
use AprsmeWeb, :live_view
alias Aprsme.Cluster.LeaderElection
require Logger
# 5 seconds - reduced from 1 second to improve performance
@refresh_interval 5_000
@impl true
def mount(_params, _session, socket) do
# Load cached status on mount for fast initial load
aprs_status = get_cached_aprs_status()
socket =
assign(socket,
page_title: "System Status",
aprs_status: aprs_status,
current_time: DateTime.utc_now(),
health_score: calculate_health_score(aprs_status),
loading: false
)
_ =
if connected?(socket) do
# Subscribe to status updates via PubSub
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "aprs_status")
# Schedule the first refresh with a slight delay
Process.send_after(self(), :refresh_status, 500)
end
{:ok, socket}
end
@impl true
def handle_info(:refresh_status, socket) do
# Refresh status asynchronously using supervised task
self_pid = self()
_ =
Task.Supervisor.start_child(Aprsme.BroadcastTaskSupervisor, fn ->
try do
status = get_aprs_status()
send(self_pid, {:status_updated, status})
rescue
error ->
Logger.error("Failed to refresh APRS status: #{inspect(error)}")
# Send empty/default status on error
send(
self_pid,
{:status_updated,
%{
connected: false,
uptime_seconds: 0,
data_rate: 0,
packet_stats: %{
packets_per_second: 0,
last_packet_at: nil
},
stored_packet_count: 0,
error: "Failed to fetch status"
}}
)
end
end)
# Schedule next refresh
schedule_refresh()
{:noreply, assign(socket, loading: true)}
end
@impl true
def handle_info({:status_updated, status}, socket) do
socket =
assign(socket,
aprs_status: status,
current_time: DateTime.utc_now(),
health_score: calculate_health_score(status),
loading: false
)
{:noreply, socket}
end
@impl true
def handle_info({:aprs_status_update, status}, socket) do
# Handle real-time status updates via PubSub
socket =
assign(socket,
aprs_status: status,
current_time: DateTime.utc_now(),
health_score: calculate_health_score(status)
)
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-4xl p-4 sm:px-6 lg:px-8">
<div class="bg-white shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<div class="px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{gettext("APRS.me System Status")}</h1>
<div class="text-sm text-gray-500 dark:text-gray-400">
{gettext("Last updated:")}
<span class="font-mono">{Calendar.strftime(@current_time, "%H:%M:%S UTC")}</span>
</div>
</div>
<!-- Overall Status Alert -->
<%= if not @aprs_status.connected do %>
<div class="rounded-md bg-red-50 p-4 mb-6 dark:bg-red-500/10">
<div class="flex">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-red-400 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">{gettext("APRS-IS Connection Issue")}</h3>
<div class="mt-1 text-sm text-red-700 dark:text-red-400">
{gettext(
"The system is currently disconnected from the APRS-IS network. This may be due to network issues or server maintenance."
)}
</div>
</div>
</div>
</div>
<% end %>
<!-- APRS-IS Connection Status -->
<div class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">{gettext("APRS-IS Connection")}</h2>
<div class="rounded-lg bg-gray-50 p-6 dark:bg-gray-700/50">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">{gettext("Status:")}</span>
<%= if @aprs_status.connected do %>
<span class="inline-flex items-center gap-1 rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-400/10 dark:text-green-400">
<span class="h-2 w-2 rounded-full bg-current"></span>
{gettext("Connected")}
</span>
<% else %>
<span class="inline-flex items-center gap-1 rounded-md bg-red-100 px-2 py-1 text-xs font-medium text-red-700 dark:bg-red-400/10 dark:text-red-400">
<span class="h-2 w-2 rounded-full bg-current"></span>
{gettext("Disconnected")}
</span>
<% end %>
</div>
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">{gettext("Server:")}</span>
<span class="text-sm font-mono text-gray-900 dark:text-white">
{@aprs_status.server}
</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">{gettext("Login ID:")}</span>
<span class="text-sm font-mono text-gray-900 dark:text-white">{@aprs_status.login_id}</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">
{gettext("Connected Since:")}
</span>
<span class="text-sm text-gray-900 dark:text-white">
<%= if @aprs_status.connected_at do %>
{Calendar.strftime(@aprs_status.connected_at, "%Y-%m-%d %H:%M:%S UTC")}
<% else %>
<span class="text-gray-400 dark:text-gray-500">{gettext("Not connected")}</span>
<% end %>
</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">{gettext("Uptime:")}</span>
<span class={[
"text-sm font-medium",
if(@aprs_status.connected,
do: "text-green-600 dark:text-green-400",
else: "text-red-600 dark:text-red-400"
)
]}>
{format_uptime(@aprs_status.uptime_seconds)}
</span>
</div>
<div class="flex items-center col-span-2">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">{gettext("Filter:")}</span>
<span class="inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium font-mono text-gray-600 dark:bg-gray-400/10 dark:text-gray-400">
{@aprs_status.filter}
</span>
</div>
</div>
<!-- Packet Statistics -->
<div class="border-t border-gray-200 dark:border-white/10 my-4"></div>
<div>
<h3 class="text-sm font-medium mb-3 text-gray-900 dark:text-white">{gettext("Packet Statistics")}</h3>
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">
{gettext("Total Packets:")}
</span>
<span class="text-sm font-mono text-gray-900 dark:text-white">
{format_number(@aprs_status.packet_stats.total_packets)}
</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">
{gettext("Packets/Sec:")}
</span>
<span class="text-sm font-mono text-gray-900 dark:text-white">
{@aprs_status.packet_stats.packets_per_second}
</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">
{gettext("Packets/Min:")}
</span>
<span class="text-sm font-mono text-gray-900 dark:text-white">
{@aprs_status.packet_stats.packets_per_second * 60}
</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">
{gettext("Last Packet:")}
</span>
<span class="text-sm text-gray-900 dark:text-white">
<%= if @aprs_status.packet_stats.last_packet_at do %>
{format_time_ago(@aprs_status.packet_stats.last_packet_at)}
<% else %>
<span class="text-gray-400 dark:text-gray-500">{gettext("None")}</span>
<% end %>
</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">
{gettext("Stored Packets:")}
</span>
<span class="text-sm font-mono text-gray-900 dark:text-white">
{format_number(@aprs_status.stored_packet_count)}
</span>
</div>
</div>
<div class="mt-2">
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">
{gettext("Oldest Packet:")}
</span>
<span class="text-sm text-gray-900 dark:text-white">
<%= if @aprs_status[:oldest_packet_timestamp] do %>
<span class="font-mono">
{Calendar.strftime(
@aprs_status.oldest_packet_timestamp,
"%Y-%m-%d %H:%M:%S UTC"
)}
</span>
<span class="text-gray-500 dark:text-gray-400">
({format_time_ago(@aprs_status.oldest_packet_timestamp)})
</span>
<% else %>
<span class="text-gray-400 dark:text-gray-500">{gettext("No packets stored")}</span>
<% end %>
</span>
</div>
</div>
</div>
<!-- Health Score -->
<div class="border-t border-gray-200 dark:border-white/10 my-4"></div>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">{gettext("Connection Health:")}</span>
<div class="flex items-center gap-1">
<%= for i <- 1..5 do %>
<svg
class={[
"h-5 w-5",
if(i <= @health_score, do: "text-amber-400", else: "text-gray-300 dark:text-gray-600")
]}
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401z"
clip-rule="evenodd"
/>
</svg>
<% end %>
<span class="ml-1 text-sm font-medium text-gray-900 dark:text-white">
{@health_score}/5
</span>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{get_health_description(@health_score, @aprs_status.connected)}
</p>
</div>
</div>
<!-- Cluster Information (if available) -->
<%= if Map.has_key?(@aprs_status, :cluster_info) do %>
<div class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">{gettext("Cluster Status")}</h2>
<div class="rounded-lg bg-gray-50 p-6 dark:bg-gray-700/50">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">{gettext("Total Nodes:")}</span>
<span class="text-sm font-mono text-gray-900 dark:text-white">
{@aprs_status.cluster_info.total_nodes}
</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">
{gettext("Connected Nodes:")}
</span>
<span class="text-sm font-mono text-gray-900 dark:text-white">
{@aprs_status.cluster_info.connected_nodes}
</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">{gettext("Leader Node:")}</span>
<span class="text-sm font-mono text-gray-900 dark:text-white">
{@aprs_status.cluster_info.leader_node}
</span>
</div>
</div>
<div class="border-t border-gray-200 dark:border-white/10 my-4"></div>
<div class="flex items-center">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 mr-2">{gettext("Cluster Mode:")}</span>
<span class="inline-flex items-center gap-1 rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-400/10 dark:text-green-400">
<span class="h-2 w-2 rounded-full bg-current"></span>
{gettext("Enabled")}
</span>
</div>
<%= if Map.has_key?(@aprs_status.cluster_info, :all_nodes) do %>
<div class="mt-4">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">{gettext("Cluster Nodes:")}</span>
<div class="flex flex-wrap gap-2 mt-2">
<%= for node_name <- @aprs_status.cluster_info.all_nodes do %>
<span class={[
"inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium font-mono",
if(node_name == @aprs_status.cluster_info.leader_node,
do: "bg-indigo-100 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-400",
else: "bg-gray-100 text-gray-600 dark:bg-gray-400/10 dark:text-gray-400"
)
]}>
<%= if node_name == @aprs_status.cluster_info.leader_node do %>
<span class="h-2 w-2 rounded-full bg-current"></span>
<% end %>
{node_name}
</span>
<% end %>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{gettext("Leader node")}
<span class="inline-flex items-center rounded-md bg-indigo-100 px-1.5 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-400">
{gettext("highlighted")}
</span>
</p>
</div>
<% end %>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
{gettext(
"Status is aggregated from all nodes in the cluster. Only the leader node maintains the APRS-IS connection."
)}
</p>
</div>
</div>
<% end %>
</div>
</div>
</div>
"""
end
# Private functions
defp get_aprs_status do
LeaderElection.get_cluster_aprs_status()
rescue
error ->
require Logger
Logger.error("Error getting APRS status: #{inspect(error)}")
# Return a default status when database is unavailable
default_status()
end
defp get_cached_aprs_status do
# Try to get cached status for instant load
case Aprsme.Cache.get(:query_cache, "aprs_status") do
{:ok, status} when not is_nil(status) ->
status
_ ->
# Fallback to direct query if cache miss
status = get_aprs_status()
Aprsme.Cache.put(:query_cache, "aprs_status", status)
status
end
end
defp default_status do
%{
connected: false,
server: "unknown",
port: 0,
connected_at: nil,
uptime_seconds: 0,
login_id: "unknown",
filter: "unknown",
packet_stats: %{
total_packets: 0,
packets_per_second: 0,
last_packet_at: nil
},
stored_packet_count: 0,
oldest_packet_timestamp: nil
}
end
defp schedule_refresh do
Process.send_after(self(), :refresh_status, @refresh_interval)
end
def format_uptime(seconds) when seconds <= 0, do: gettext("Not connected")
def format_uptime(seconds) do
days = div(seconds, 86_400)
hours = div(rem(seconds, 86_400), 3600)
minutes = div(rem(seconds, 3600), 60)
secs = rem(seconds, 60)
cond do
days > 0 -> "#{days}d #{hours}h #{minutes}m #{secs}s"
hours > 0 -> "#{hours}h #{minutes}m #{secs}s"
minutes > 0 -> "#{minutes}m #{secs}s"
true -> "#{secs}s"
end
end
defp calculate_health_score(aprs_status) do
cond do
not aprs_status.connected -> 1
# Less than 5 minutes
aprs_status.uptime_seconds < 300 -> 2
# Less than 1 hour
aprs_status.uptime_seconds < 3600 -> 3
# Less than 1 day
aprs_status.uptime_seconds < 86_400 -> 4
# More than 1 day
true -> 5
end
end
def get_health_description(score, connected) do
case {score, connected} do
{1, false} -> gettext("Disconnected - Connection issues detected")
{2, true} -> gettext("Recently connected - Monitoring stability")
{3, true} -> gettext("Good - Connection stable for less than 1 hour")
{4, true} -> gettext("Very good - Connection stable for less than 1 day")
{5, true} -> gettext("Excellent - Long-term stable connection")
_ -> gettext("Unknown status")
end
end
def format_time_ago(nil), do: gettext("Never")
def format_time_ago(datetime) do
diff_seconds = DateTime.diff(DateTime.utc_now(), datetime)
cond do
diff_seconds < 60 -> gettext("%{count} seconds ago", count: diff_seconds)
diff_seconds < 3600 -> gettext("%{count} minutes ago", count: div(diff_seconds, 60))
diff_seconds < 86_400 -> gettext("%{count} hours ago", count: div(diff_seconds, 3600))
true -> gettext("%{count} days ago", count: div(diff_seconds, 86_400))
end
end
def format_number(number) when is_integer(number) do
number
|> Integer.to_string()
|> String.reverse()
|> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
|> String.reverse()
end
def format_number(number), do: to_string(number)
end