From 765b0b25e9e24ef85e2f9e96c54938e399b622ca Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 15 Jun 2025 15:39:14 -0500 Subject: [PATCH] status page --- lib/aprs/is/is.ex | 115 +++++- lib/aprs_web/controllers/page_controller.ex | 44 +++ lib/aprs_web/live/status_live/index.ex | 405 ++++++++++++++++++++ lib/aprs_web/router.ex | 2 + 4 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 lib/aprs_web/live/status_live/index.ex diff --git a/lib/aprs/is/is.ex b/lib/aprs/is/is.ex index a8e450e..507ecde 100644 --- a/lib/aprs/is/is.ex +++ b/lib/aprs/is/is.ex @@ -26,6 +26,18 @@ defmodule Aprs.Is do aprs_user_id = Application.get_env(:aprs, :aprs_is_login_id, "W5ISP") aprs_passcode = Application.get_env(:aprs, :aprs_is_password, "-1") + # Record connection start time + connected_at = DateTime.utc_now() + + # Initialize packet statistics + packet_stats = %{ + total_packets: 0, + last_packet_at: nil, + packets_per_minute: 0, + last_minute_count: 0, + last_minute_timestamp: System.system_time(:second) + } + # Set up ets tables # with {:ok, :aprs} <- :ets.file2tab(:erlang.binary_to_list("priv/aprs.ets")), @@ -57,6 +69,8 @@ defmodule Aprs.Is do socket: socket, timer: timer, keepalive_timer: keepalive_timer, + connected_at: connected_at, + packet_stats: packet_stats, login_params: %{ user_id: aprs_user_id, passcode: aprs_passcode, @@ -77,6 +91,47 @@ defmodule Aprs.Is do GenServer.stop(__MODULE__, :stop) end + def get_status do + case Process.whereis(__MODULE__) do + nil -> + # GenServer is not running (disconnected) + server = Application.get_env(:aprs, :aprs_is_server, ~c"rotate.aprs2.net") + port = Application.get_env(:aprs, :aprs_is_port, 14_580) + + %{ + connected: false, + server: server_to_string(server), + port: port, + connected_at: nil, + uptime_seconds: 0, + login_id: Application.get_env(:aprs, :aprs_is_login_id, "W5ISP"), + filter: Application.get_env(:aprs, :aprs_is_default_filter, "r/33/-96/100"), + packet_stats: default_packet_stats() + } + + _pid -> + try do + GenServer.call(__MODULE__, :get_status, 5000) + catch + :exit, _ -> + # GenServer exists but not responding + server = Application.get_env(:aprs, :aprs_is_server, ~c"rotate.aprs2.net") + port = Application.get_env(:aprs, :aprs_is_port, 14_580) + + %{ + connected: false, + server: server_to_string(server), + port: port, + connected_at: nil, + uptime_seconds: 0, + login_id: Application.get_env(:aprs, :aprs_is_login_id, "W5ISP"), + filter: Application.get_env(:aprs, :aprs_is_default_filter, "r/33/-96/100"), + packet_stats: default_packet_stats() + } + end + end + end + def set_filter(filter_string), do: send_message("#filter #{filter_string}") def list_active_filters, do: send_message("#filter?") @@ -125,6 +180,21 @@ defmodule Aprs.Is do {:reply, :ok, state} end + def handle_call(:get_status, _from, state) do + status = %{ + connected: true, + server: server_to_string(state.server), + port: state.port, + connected_at: state.connected_at, + uptime_seconds: DateTime.diff(DateTime.utc_now(), state.connected_at), + login_id: state.login_params.user_id, + filter: state.login_params.filter, + packet_stats: state.packet_stats + } + + {:reply, status, state} + end + @impl true def handle_info(:aprs_no_message_timeout, state) do Logger.error("Socket timeout detected. Killing genserver.") @@ -149,6 +219,10 @@ defmodule Aprs.Is do # Cancel the previous timer Process.cancel_timer(state.timer) + # Update packet statistics + current_time = System.system_time(:second) + packet_stats = update_packet_stats(state.packet_stats, current_time) + # Handle the incoming message # Task.start(Aprs, :dispatch, [packet]) @@ -162,7 +236,7 @@ defmodule Aprs.Is do # Start a new timer timer = Process.send_after(self(), :aprs_no_message_timeout, @aprs_timeout) - state = Map.put(state, :timer, timer) + state = state |> Map.put(:timer, timer) |> Map.put(:packet_stats, packet_stats) {:noreply, state} end @@ -277,4 +351,43 @@ defmodule Aprs.Is do # total_spec = [{{:"$1", :_, :"$2"}, [{:andalso, callsign_guard, timestamp_guard}], [true]}] # :ets.select_count(:aprs_messages, total_spec) # end + + defp update_packet_stats(stats, current_time) do + new_total = stats.total_packets + 1 + + # Check if we need to reset the per-minute counter + if current_time - stats.last_minute_timestamp >= 60 do + %{ + total_packets: new_total, + last_packet_at: DateTime.utc_now(), + packets_per_minute: 1, + last_minute_count: 1, + last_minute_timestamp: current_time + } + else + new_minute_count = stats.last_minute_count + 1 + + %{ + stats + | total_packets: new_total, + last_packet_at: DateTime.utc_now(), + packets_per_minute: new_minute_count, + last_minute_count: new_minute_count + } + end + end + + defp server_to_string(server) when is_list(server), do: List.to_string(server) + defp server_to_string(server) when is_binary(server), do: server + defp server_to_string(server), do: to_string(server) + + defp default_packet_stats do + %{ + total_packets: 0, + last_packet_at: nil, + packets_per_minute: 0, + last_minute_count: 0, + last_minute_timestamp: System.system_time(:second) + } + end end diff --git a/lib/aprs_web/controllers/page_controller.ex b/lib/aprs_web/controllers/page_controller.ex index 57ee42d..4c3d779 100644 --- a/lib/aprs_web/controllers/page_controller.ex +++ b/lib/aprs_web/controllers/page_controller.ex @@ -56,4 +56,48 @@ defmodule AprsWeb.PageController do timestamp: DateTime.utc_now() }) end + + def status_json(conn, _params) do + # Get APRS-IS connection status + aprs_status = Aprs.Is.get_status() + + # Get application version + version = :aprs |> Application.spec(:vsn) |> List.to_string() + + # Calculate uptime in a human-readable format + uptime_display = format_uptime(aprs_status.uptime_seconds) + + json(conn, %{ + aprs_is: %{ + connected: aprs_status.connected, + server: aprs_status.server, + port: aprs_status.port, + connected_at: aprs_status.connected_at, + uptime_seconds: aprs_status.uptime_seconds, + uptime_display: uptime_display, + login_id: aprs_status.login_id, + filter: aprs_status.filter + }, + application: %{ + version: version, + timestamp: DateTime.utc_now() + } + }) + end + + defp format_uptime(seconds) when seconds <= 0, do: "Not connected" + + defp 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 end diff --git a/lib/aprs_web/live/status_live/index.ex b/lib/aprs_web/live/status_live/index.ex new file mode 100644 index 0000000..40a6c93 --- /dev/null +++ b/lib/aprs_web/live/status_live/index.ex @@ -0,0 +1,405 @@ +defmodule AprsWeb.StatusLive.Index do + @moduledoc """ + LiveView for displaying real-time APRS-IS connection status + """ + use AprsWeb, :live_view + + # 30 seconds + @refresh_interval 30_000 + + @impl true + def mount(_params, _session, socket) do + socket = + assign(socket, + page_title: "System Status", + aprs_status: get_aprs_status(), + version: get_app_version(), + current_time: DateTime.utc_now(), + auto_refresh: true, + health_score: calculate_health_score(get_aprs_status()) + ) + + if connected?(socket) do + # Schedule the first refresh + schedule_refresh() + end + + {:ok, socket} + end + + @impl true + def handle_event("toggle_auto_refresh", _params, socket) do + new_auto_refresh = !socket.assigns.auto_refresh + + socket = assign(socket, auto_refresh: new_auto_refresh) + + if new_auto_refresh do + schedule_refresh() + end + + {:noreply, socket} + end + + @impl true + def handle_event("refresh_now", _params, socket) do + socket = refresh_status(socket) + {:noreply, socket} + end + + @impl true + def handle_info(:refresh_status, socket) do + socket = refresh_status(socket) + + # Schedule next refresh if auto-refresh is enabled + if socket.assigns.auto_refresh do + schedule_refresh() + end + + {:noreply, socket} + end + + @impl true + def render(assigns) do + ~H""" +
+
+
+
+

APRS.me System Status

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

+ APRS-IS Connection Issue +

+
+

+ The system is currently disconnected from the APRS-IS network. This may be due to network issues or server maintenance. +

+
+
+
+
+ <% end %> + + +
+

APRS-IS Connection

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

Packet Statistics

+
+
+ Total Packets: + + {format_number(@aprs_status.packet_stats.total_packets)} + +
+ +
+ Packets/Min: + + {@aprs_status.packet_stats.packets_per_minute} + +
+ +
+ Last Packet: + + <%= if @aprs_status.packet_stats.last_packet_at do %> + {format_time_ago(@aprs_status.packet_stats.last_packet_at)} + <% else %> + None + <% end %> + +
+
+
+ + +
+
+ Connection Health: +
+
+ <%= for i <- 1..5 do %> + + + + <% end %> +
+ + {@health_score}/5 + +
+
+

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

+
+
+
+ + +
+

Application Information

+
+
+
+ Version: + {@version} +
+ +
+ Current Time: + + {Calendar.strftime(@current_time, "%Y-%m-%d %H:%M:%S UTC")} + +
+
+
+
+ + +
+
+
+
+ + + +
+
+

+ Auto-refresh: + + {if @auto_refresh, do: "ON", else: "OFF"} + + <%= if @auto_refresh do %> + (every 30 seconds) + <% end %> +

+
+
+
+ + +
+
+
+
+
+
+ """ + end + + # Private functions + + defp get_aprs_status do + Aprs.Is.get_status() + end + + defp get_app_version do + :aprs |> Application.spec(:vsn) |> List.to_string() + end + + defp refresh_status(socket) do + aprs_status = get_aprs_status() + + assign(socket, + aprs_status: aprs_status, + current_time: DateTime.utc_now(), + health_score: calculate_health_score(aprs_status) + ) + end + + defp schedule_refresh do + Process.send_after(self(), :refresh_status, @refresh_interval) + end + + defp format_uptime(seconds) when seconds <= 0, do: "Not connected" + + defp 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 + + defp get_health_description(score, connected) do + case {score, connected} do + {1, false} -> "Disconnected - Connection issues detected" + {2, true} -> "Recently connected - Monitoring stability" + {3, true} -> "Good - Connection stable for less than 1 hour" + {4, true} -> "Very good - Connection stable for less than 1 day" + {5, true} -> "Excellent - Long-term stable connection" + _ -> "Unknown status" + end + end + + defp format_time_ago(datetime) do + diff_seconds = DateTime.diff(DateTime.utc_now(), datetime) + + cond do + diff_seconds < 60 -> "#{diff_seconds} seconds ago" + diff_seconds < 3600 -> "#{div(diff_seconds, 60)} minutes ago" + diff_seconds < 86_400 -> "#{div(diff_seconds, 3600)} hours ago" + true -> "#{div(diff_seconds, 86_400)} days ago" + end + end + + defp format_number(number) when is_integer(number) do + number + |> Integer.to_string() + |> String.reverse() + |> String.replace(~r/(\d{3})(?=\d)/, "\\1,") + |> String.reverse() + end + + defp format_number(number), do: to_string(number) +end diff --git a/lib/aprs_web/router.ex b/lib/aprs_web/router.ex index d2a034d..3c209ae 100644 --- a/lib/aprs_web/router.ex +++ b/lib/aprs_web/router.ex @@ -21,6 +21,7 @@ defmodule AprsWeb.Router do pipe_through :api get "/health", PageController, :health get "/ready", PageController, :ready + get "/status.json", PageController, :status_json end scope "/", AprsWeb do @@ -28,6 +29,7 @@ defmodule AprsWeb.Router do live "/", MapLive.Index, :index get "/map", PageController, :map + live "/status", StatusLive.Index, :index live "/packets", PacketsLive.Index, :index end