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"""

{gettext("APRS.me System Status")}

{gettext("Last updated:")} {Calendar.strftime(@current_time, "%H:%M:%S UTC")}
<%= if not @aprs_status.connected do %>

{gettext("APRS-IS Connection Issue")}

{gettext( "The system is currently disconnected from the APRS-IS network. This may be due to network issues or server maintenance." )}
<% end %>

{gettext("APRS-IS Connection")}

{gettext("Status:")} <%= if @aprs_status.connected do %> {gettext("Connected")} <% else %> {gettext("Disconnected")} <% end %>
{gettext("Server:")} {@aprs_status.server}
{gettext("Login ID:")} {@aprs_status.login_id}
{gettext("Connected Since:")} <%= if @aprs_status.connected_at do %> {Calendar.strftime(@aprs_status.connected_at, "%Y-%m-%d %H:%M:%S UTC")} <% else %> {gettext("Not connected")} <% end %>
{gettext("Uptime:")} {format_uptime(@aprs_status.uptime_seconds)}
{gettext("Filter:")} {@aprs_status.filter}

{gettext("Packet Statistics")}

{gettext("Total Packets:")} {format_number(@aprs_status.packet_stats.total_packets)}
{gettext("Packets/Sec:")} {@aprs_status.packet_stats.packets_per_second}
{gettext("Packets/Min:")} {@aprs_status.packet_stats.packets_per_second * 60}
{gettext("Last Packet:")} <%= if @aprs_status.packet_stats.last_packet_at do %> {format_time_ago(@aprs_status.packet_stats.last_packet_at)} <% else %> {gettext("None")} <% end %>
{gettext("Stored Packets:")} {format_number(@aprs_status.stored_packet_count)}
{gettext("Oldest Packet:")} <%= if @aprs_status[:oldest_packet_timestamp] do %> {Calendar.strftime( @aprs_status.oldest_packet_timestamp, "%Y-%m-%d %H:%M:%S UTC" )} ({format_time_ago(@aprs_status.oldest_packet_timestamp)}) <% else %> {gettext("No packets stored")} <% end %>
{gettext("Connection Health:")}
<%= for i <- 1..5 do %> <% end %> {@health_score}/5

{get_health_description(@health_score, @aprs_status.connected)}

<%= if Map.has_key?(@aprs_status, :cluster_info) do %>

{gettext("Cluster Status")}

{gettext("Total Nodes:")} {@aprs_status.cluster_info.total_nodes}
{gettext("Connected Nodes:")} {@aprs_status.cluster_info.connected_nodes}
{gettext("Leader Node:")} {@aprs_status.cluster_info.leader_node}
{gettext("Cluster Mode:")} {gettext("Enabled")}
<%= if Map.has_key?(@aprs_status.cluster_info, :all_nodes) do %>
{gettext("Cluster Nodes:")}
<%= for node_name <- @aprs_status.cluster_info.all_nodes do %> <%= if node_name == @aprs_status.cluster_info.leader_node do %> <% end %> {node_name} <% end %>

{gettext("Leader node")} {gettext("highlighted")}

<% end %>

{gettext( "Status is aggregated from all nodes in the cluster. Only the leader node maintains the APRS-IS connection." )}

<% end %>
""" 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