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""" +
+ The system is currently disconnected from the APRS-IS network. This may be due to network issues or server maintenance. +
++ {get_health_description(@health_score, @aprs_status.connected)} +
++ Auto-refresh: + + {if @auto_refresh, do: "ON", else: "OFF"} + + <%= if @auto_refresh do %> + (every 30 seconds) + <% end %> +
+