fix: resolve 19 bugs across Elixir, JS/TS, and config

Critical:
- Remove :protected from WeatherCache ETS (conflicted with :public)
- Register Aprsme.ReplayRegistry in supervision tree
- Route :continue_replay dead message to :start_replay handler
- Add 5000ms timeout to unbounded :rpc.call calls
- Fix falsy lat/lng check rejecting valid 0 coordinates

Medium:
- Fix live_reload pattern (temp_web -> aprsme_web)
- Remove duplicate ecto_repos config
- Make DB SSL configurable via DB_SSL env var
- Track sizeCheckTimeout on self for cleanup in destroyed()
- Wrap pushEvent calls in try/catch
- Remove unused size variable in marker cluster icon
- Capture touch coords at touchstart instead of stale TouchEvent
- Demote console.log to console.debug in production code
- Add catch-all to normalize_bounds preventing GenServer crash

Style:
- Add missing @impl true annotations in is.ex
- Improve migration failure error message
- Use textContent instead of innerHTML for error messages
- Use proper type for longPressTimer instead of any
This commit is contained in:
Graham McIntire 2026-06-21 11:47:07 -05:00
parent b86153cd27
commit 6eda1c0290
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
12 changed files with 66 additions and 48 deletions

View file

@ -277,13 +277,13 @@ function createChartHook(configKey: string): Hook {
self.el.dataset.weatherHistory,
);
if (data.length === 0) {
console.log("No weather data available for chart");
console.debug("No weather data available for chart");
return;
}
// Skip rendering if we have less than 2 data points (can't create a meaningful time series)
if (data.length < 2) {
console.log(
console.debug(
"Insufficient weather data for chart (need at least 2 data points)",
);
return;

View file

@ -80,7 +80,6 @@ const DISABLE_CLUSTERING_AT_ZOOM = 11;
let MapAPRSMap = {
mounted() {
const self = this as unknown as LiveViewHookContext;
console.log("APRSMap hook mounted() called on element:", this.el);
// Initialize error tracking
self.errors = [];
self.initializationAttempts = 0;
@ -346,15 +345,12 @@ let MapAPRSMap = {
maxClusterRadius: 80,
iconCreateFunction: function (cluster: MarkerCluster) {
const count = cluster.getChildCount();
let size = "small";
let className = "marker-cluster-small";
if (count > 10) {
size = "medium";
className = "marker-cluster-medium";
}
if (count > 50) {
size = "large";
className = "marker-cluster-large";
}
@ -462,7 +458,11 @@ let MapAPRSMap = {
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
self.pushEvent("map_ready", {});
try {
self.pushEvent("map_ready", {});
} catch (e) {
console.debug("Failed to push map_ready event:", e);
}
if (self.map) {
saveMapState(self.map, self.pushEvent.bind(self));
self.sendBoundsToServer();
@ -492,7 +492,7 @@ let MapAPRSMap = {
// Process any pending markers that were queued before map was ready
if (self.pendingMarkers && self.pendingMarkers.length > 0) {
console.log(
console.debug(
`Processing ${self.pendingMarkers.length} pending markers`,
);
self.pendingMarkers.forEach((markerData: MarkerData) => {
@ -598,7 +598,11 @@ let MapAPRSMap = {
// If zoom changed significantly (more than 2 levels), request reload of normal markers
// but preserve historical ones and current position
if (zoomDifference > 2) {
self.pushEvent("refresh_markers", {});
try {
self.pushEvent("refresh_markers", {});
} catch (e) {
console.debug("Failed to push refresh_markers event:", e);
}
}
self.lastZoom = currentZoom;
@ -629,7 +633,7 @@ let MapAPRSMap = {
window.addEventListener("resize", self.resizeHandler);
// Add a delayed size check
setTimeout(() => {
self.sizeCheckTimeout = setTimeout(() => {
if (self.el && self.el.parentNode) {
const rect = self.el.getBoundingClientRect();
if (self.map && rect.width > 0 && rect.height > 0) {
@ -651,7 +655,7 @@ let MapAPRSMap = {
// Process any pending markers once the map is ready
self.map.whenReady(() => {
if (self.pendingMarkers && self.pendingMarkers.length > 0) {
console.log(`Processing ${self.pendingMarkers.length} pending markers`);
console.debug(`Processing ${self.pendingMarkers.length} pending markers`);
self.pendingMarkers.forEach((markerData) => {
self.addMarker(markerData);
});
@ -721,13 +725,15 @@ let MapAPRSMap = {
">
<div>
<h3 style="margin: 0 0 10px 0;">Map Loading Error</h3>
<p style="margin: 0; font-size: 14px;">${message}</p>
<p id="map-error-message" style="margin: 0; font-size: 14px;"></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #856404;">
Please refresh the page or check the browser console for details.
</p>
</div>
</div>
`;
const msgEl = self.el.querySelector('#map-error-message');
if (msgEl) msgEl.textContent = message;
}
},
@ -735,18 +741,15 @@ let MapAPRSMap = {
const self = this as unknown as LiveViewHookContext;
// Long press timer
let longPressTimer: any = null;
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let startX = 0;
let startY = 0;
const longPressDuration = 750; // 750ms for long press
const movementThreshold = 10; // pixels
const handleLongPress = (e: TouchEvent) => {
if (e.touches.length !== 1) return; // Only handle single touch
const touch = e.touches[0];
const handleLongPress = (clientX: number, clientY: number) => {
const latlng = self.map!.containerPointToLatLng(
L.point(touch.clientX, touch.clientY),
L.point(clientX, clientY),
);
// Find the nearest marker
@ -781,7 +784,7 @@ let MapAPRSMap = {
longPressTimer = setTimeout(() => {
if (!self.isDestroyed && self.map) {
handleLongPress(e.originalEvent);
handleLongPress(touch.clientX, touch.clientY);
}
}, longPressDuration);
});
@ -1333,7 +1336,7 @@ let MapAPRSMap = {
self.handleEvent(
"add_historical_packets_batch",
(data: { packets: MarkerData[]; batch: number; is_final: boolean }) => {
console.log("Received add_historical_packets_batch event:", {
console.debug("Received add_historical_packets_batch event:", {
packetCount: data.packets?.length || 0,
batch: data.batch,
is_final: data.is_final,
@ -1726,7 +1729,7 @@ let MapAPRSMap = {
sendBoundsToServer() {
const self = this as unknown as LiveViewHookContext;
console.log(
console.debug(
"sendBoundsToServer called, map:",
!!self.map,
"isDestroyed:",
@ -1758,7 +1761,7 @@ let MapAPRSMap = {
},
zoom: zoom,
};
console.log("Sending bounds_changed event:", boundsData);
console.debug("Sending bounds_changed event:", boundsData);
self.pushEvent("bounds_changed", boundsData);
}
} catch (error) {
@ -1772,8 +1775,8 @@ let MapAPRSMap = {
if (
!data ||
!data.id ||
!data.lat ||
!data.lng ||
data.lat == null ||
data.lng == null ||
typeof data.lat !== "number" ||
typeof data.lng !== "number"
) {
@ -2500,6 +2503,12 @@ let MapAPRSMap = {
self.programmaticMoveTimeout = undefined;
}
// Clear size check timeout
if (self.sizeCheckTimeout !== undefined) {
clearTimeout(self.sizeCheckTimeout);
self.sizeCheckTimeout = undefined;
}
// Clear initialization timeout
if (self.initializationTimeout !== undefined) {
clearTimeout(self.initializationTimeout);

View file

@ -54,9 +54,6 @@ config :aprsme, :position_tracking,
# Position change threshold in degrees (~100 meters at equator)
change_threshold: 0.001
config :aprsme,
ecto_repos: [Aprsme.Repo]
config :aprsme,
ecto_repos: [Aprsme.Repo],
aprs_is_server: System.get_env("APRS_SERVER", "dallas.aprs2.net"),

View file

@ -63,7 +63,7 @@ config :aprsme, AprsmeWeb.Endpoint,
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/temp_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$"
~r"lib/aprsme_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$"
]
]

View file

@ -58,7 +58,7 @@ if config_env() == :prod do
config :aprsme, Aprsme.Repo,
url: database_url,
# Disable SSL/TLS for database connections
ssl: false,
ssl: System.get_env("DB_SSL", "false") == "true",
# Increased pool size for better concurrency (was 25)
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "45"),
# Increased timeout for ARM system under load

View file

@ -48,6 +48,8 @@ defmodule Aprsme.Application do
# Start a worker by calling: Aprsme.Worker.start_link(arg)
# {Aprsme.Worker, arg}
{Registry, keys: :duplicate, name: Registry.PubSub, partitions: System.schedulers_online()},
# Start ReplayRegistry for unique replay sessions per user
{Registry, keys: :unique, name: Aprsme.ReplayRegistry, partitions: System.schedulers_online()},
# Start cleanup scheduler for periodic packet cleanup
Aprsme.CleanupScheduler,
Aprsme.PostgresNotifier,
@ -130,7 +132,7 @@ defmodule Aprsme.Application do
# Gettext translations are automatically compiled during Mix compilation
rescue
error ->
Logger.error("Failed to run migrations: #{inspect(error)}")
Logger.error("Failed to run migrations: #{inspect(error)}. Application may be in an inconsistent state.")
# Don't crash the application, just log the error
:ok
end

View file

@ -316,7 +316,7 @@ defmodule Aprsme.Cluster.LeaderElection do
end
defp pid_alive?(pid, pid_node) do
case :rpc.call(pid_node, Process, :alive?, [pid]) do
case :rpc.call(pid_node, Process, :alive?, [pid], 5000) do
{:badrpc, _} -> false
result -> result == true
end

View file

@ -144,7 +144,7 @@ defmodule Aprsme.ConnectionMonitor do
# Get stats from remote nodes via RPC
remote_stats =
Enum.reduce(Node.list(), %{}, fn remote_node, acc ->
case :rpc.call(remote_node, __MODULE__, :get_stats, []) do
case :rpc.call(remote_node, __MODULE__, :get_stats, [], 5000) do
{:badrpc, _} -> acc
stats -> Map.put(acc, remote_node, stats)
end

View file

@ -285,6 +285,7 @@ defmodule Aprsme.Is do
end
end
@impl true
def handle_call(:get_status, _from, state) do
connected = state.socket != nil
@ -317,6 +318,7 @@ defmodule Aprsme.Is do
end
end
@impl true
def handle_info(:send_keepalive, state) do
case state.socket do
nil ->
@ -345,6 +347,7 @@ defmodule Aprsme.Is do
handle_socket_data(data, state)
end
@impl true
def handle_info({:backpressure, :activate}, %{socket: nil} = state) do
{:noreply, state}
end
@ -363,6 +366,7 @@ defmodule Aprsme.Is do
{:noreply, %{state | backpressure_active: true, timer: nil, safety_valve_timer: safety_valve_timer}}
end
@impl true
def handle_info({:backpressure, :deactivate}, %{backpressure_active: false} = state) do
{:noreply, state}
end
@ -382,6 +386,7 @@ defmodule Aprsme.Is do
{:noreply, %{state | backpressure_active: false, safety_valve_timer: nil, timer: timer}}
end
@impl true
def handle_info(:backpressure_safety_valve, %{backpressure_active: false} = state) do
{:noreply, state}
end
@ -398,6 +403,7 @@ defmodule Aprsme.Is do
{:noreply, %{state | backpressure_active: false, safety_valve_timer: nil, timer: timer}}
end
@impl true
def handle_info({:tcp_closed, _socket}, state) do
Logger.warning("Socket has been closed by remote server - will reconnect")
# Cancel any existing timers
@ -421,6 +427,7 @@ defmodule Aprsme.Is do
{:noreply, state}
end
@impl true
def handle_info({:tcp_error, _socket, reason}, state) do
Logger.error("Connection error: #{inspect(reason)} - will reconnect")
# Cancel any existing timers
@ -444,6 +451,7 @@ defmodule Aprsme.Is do
{:noreply, state}
end
@impl true
def handle_info(:reconnect, state) do
Logger.info("Attempting to reconnect to APRS-IS...")

View file

@ -297,7 +297,7 @@ defmodule Aprsme.PacketReplay do
@impl true
def handle_call(:resume, _from, %{paused: true} = state) do
# Force the next packet to be sent soon
send(self(), {:continue_replay})
send(self(), :start_replay)
_ =
Endpoint.broadcast(state.replay_topic, "replay_resumed", %{

View file

@ -251,6 +251,12 @@ defmodule Aprsme.SpatialPubSub do
}
end
defp normalize_bounds(invalid) do
require Logger
Logger.warning("normalize_bounds called with invalid bounds map: #{inspect(invalid)}")
%{north: 0.0, south: 0.0, east: 0.0, west: 0.0}
end
defp ensure_float(val) when is_binary(val) do
case Float.parse(val) do
{f, _} -> f

View file

@ -14,23 +14,19 @@ defmodule Aprsme.WeatherCache do
The table is typically created in application.ex alongside other ETS tables.
"""
def setup do
_ =
case :ets.info(@cache_name) do
:undefined ->
:ets.new(@cache_name, [
:named_table,
:public,
:set,
:protected,
read_concurrency: true,
write_concurrency: true
])
_ ->
:ok
end
if :ets.whereis(@cache_name) == :undefined do
:ets.new(@cache_name, [
:named_table,
:public,
:set,
read_concurrency: true,
write_concurrency: true
])
end
:ok
rescue
ArgumentError -> :ok
end
@doc """