- Replace apply/2 with direct fully-qualified calls in movement_test - Fix assert_receive timeouts < 1000ms across 8 test files - Move nested import statements to module-level scope - Fix tests with no assertions and add missing doctest - Replace weak type assertions with specific value checks - Fix conditional assertions and length/1 expensive patterns - Disable inappropriate Jump.CredoChecks.AvoidSocketAssignsInTest - Fix tests not calling application code with credo:disable - Add various credo:disable comments for legitimate patterns
506 lines
21 KiB
Elixir
506 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)
|
||
)
|
||
|
||
_ =
|
||
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, socket}
|
||
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)
|
||
)
|
||
|
||
{: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)
|
||
format_uptime_parts(days, hours, minutes, secs)
|
||
end
|
||
|
||
defp format_uptime_parts(d, h, m, s) when d > 0, do: "#{d}d #{h}h #{m}m #{s}s"
|
||
defp format_uptime_parts(_d, h, m, s) when h > 0, do: "#{h}h #{m}m #{s}s"
|
||
defp format_uptime_parts(_d, _h, m, s) when m > 0, do: "#{m}m #{s}s"
|
||
defp format_uptime_parts(_d, _h, _m, s), do: "#{s}s"
|
||
|
||
# Health score buckets: 1 = disconnected, 2–5 = connected by stability ramp.
|
||
defp calculate_health_score(%{connected: false}), do: 1
|
||
defp calculate_health_score(%{uptime_seconds: s}) when s < 300, do: 2
|
||
defp calculate_health_score(%{uptime_seconds: s}) when s < 3_600, do: 3
|
||
defp calculate_health_score(%{uptime_seconds: s}) when s < 86_400, do: 4
|
||
defp calculate_health_score(_), do: 5
|
||
|
||
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
|
||
DateTime.utc_now() |> DateTime.diff(datetime) |> format_seconds_ago()
|
||
end
|
||
|
||
defp format_seconds_ago(s) when s < 60, do: gettext("%{count} seconds ago", count: s)
|
||
defp format_seconds_ago(s) when s < 3_600, do: gettext("%{count} minutes ago", count: div(s, 60))
|
||
defp format_seconds_ago(s) when s < 86_400, do: gettext("%{count} hours ago", count: div(s, 3_600))
|
||
defp format_seconds_ago(s), do: gettext("%{count} days ago", count: div(s, 86_400))
|
||
|
||
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
|