Add cluster-aware APRS-IS status checking

Both /status and /status.json endpoints now show cluster-wide APRS-IS
connection status instead of just local node status.

Features:
- get_cluster_aprs_status() checks all nodes in cluster via RPC
- Returns status from any connected node (usually the leader)
- Shows cluster info: total nodes, connected nodes, leader node
- Graceful fallback to local status in non-clustered mode
- Enhanced status page UI with cluster information display

This ensures status pages accurately reflect the true APRS-IS
connection state across the entire cluster.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Graham McIntire 2025-07-25 13:10:23 -05:00
parent 37c9ac79ed
commit 0f26cd7e22
No known key found for this signature in database
3 changed files with 122 additions and 3 deletions

View file

@ -22,6 +22,21 @@ defmodule Aprsme.Cluster.LeaderElection do
GenServer.call(__MODULE__, :current_leader)
end
@doc """
Gets APRS-IS status from across the entire cluster.
Returns the status from whichever node has an active connection.
"""
def get_cluster_aprs_status do
cluster_enabled = Application.get_env(:aprsme, :cluster_enabled, false)
if cluster_enabled do
get_cluster_wide_status()
else
# Non-clustered mode - just return local status
Aprsme.Is.get_status()
end
end
@impl true
def init(_opts) do
Logger.info("Starting leader election process")
@ -179,6 +194,67 @@ defmodule Aprsme.Cluster.LeaderElection do
end
end
defp get_cluster_wide_status do
all_nodes = [node() | Node.list()]
# Check each node for APRS-IS connection status
connected_statuses =
all_nodes
|> Enum.map(&get_node_status/1)
|> Enum.filter(fn status -> status.connected end)
case connected_statuses do
[status | _] ->
# At least one node is connected - return its status
# Add cluster info to indicate this is cluster-wide status
Map.put(status, :cluster_info, %{
total_nodes: length(all_nodes),
connected_nodes: length(connected_statuses),
leader_node: get_leader_node_name()
})
[] ->
# No nodes are connected - return local status but mark as cluster-wide
local_status = Aprsme.Is.get_status()
Map.put(local_status, :cluster_info, %{
total_nodes: length(all_nodes),
connected_nodes: 0,
leader_node: get_leader_node_name()
})
end
end
defp get_node_status(node_name) do
if node_name == node() do
# Local node - call directly
Aprsme.Is.get_status()
else
# Remote node - use RPC
case :rpc.call(node_name, Aprsme.Is, :get_status, [], 5000) do
{:badrpc, _reason} ->
# Node unreachable - return disconnected status
%{connected: false, server: "unreachable", port: 0}
status when is_map(status) ->
status
_ ->
%{connected: false, server: "error", port: 0}
end
end
rescue
_error ->
%{connected: false, server: "error", port: 0}
end
defp get_leader_node_name do
case :global.whereis_name(@election_key) do
:undefined -> "none"
pid when is_pid(pid) -> pid |> node() |> to_string()
end
end
defp notify_leadership_change(became_leader) do
Phoenix.PubSub.broadcast(
Aprsme.PubSub,

View file

@ -55,8 +55,8 @@ defmodule AprsmeWeb.PageController do
end
def status_json(conn, _params) do
# Get APRS-IS connection status
aprs_status = Aprsme.Is.get_status()
# Get cluster-wide APRS-IS connection status
aprs_status = Aprsme.Cluster.LeaderElection.get_cluster_aprs_status()
# Get application version
version = :aprsme |> Application.spec(:vsn) |> List.to_string()

View file

@ -283,6 +283,49 @@ defmodule AprsmeWeb.StatusLive.Index do
</div>
</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">{gettext("Cluster Status")}</h2>
<div class="card bg-base-200">
<div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex items-center">
<span class="text-sm font-medium opacity-70 mr-2">{gettext("Total Nodes:")}</span>
<span class="text-sm font-mono">{@aprs_status.cluster_info.total_nodes}</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium opacity-70 mr-2">{gettext("Connected Nodes:")}</span>
<span class="text-sm font-mono">{@aprs_status.cluster_info.connected_nodes}</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium opacity-70 mr-2">{gettext("Leader Node:")}</span>
<span class="text-sm font-mono">{@aprs_status.cluster_info.leader_node}</span>
</div>
</div>
<div class="divider"></div>
<div class="flex items-center">
<span class="text-sm font-medium opacity-70 mr-2">{gettext("Cluster Mode:")}</span>
<div class="badge badge-success gap-1">
<div class="w-2 h-2 bg-current rounded-full"></div>
{gettext("Enabled")}
</div>
</div>
<p class="text-xs opacity-70 mt-2">
{gettext(
"Status is aggregated from all nodes in the cluster. Only the leader node maintains the APRS-IS connection."
)}
</p>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
@ -292,7 +335,7 @@ defmodule AprsmeWeb.StatusLive.Index do
# Private functions
defp get_aprs_status do
Aprsme.Is.get_status()
Aprsme.Cluster.LeaderElection.get_cluster_aprs_status()
rescue
error ->
require Logger