refactor
This commit is contained in:
parent
265c58b9ff
commit
070b8870df
6 changed files with 699 additions and 159 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -54,3 +54,6 @@ gleam@@compile.erl
|
|||
/test/screenshots/
|
||||
|
||||
.claudecheckpoints/
|
||||
|
||||
# Expert language server directory
|
||||
/.expert
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
defmodule Aprsme.DeviceParser do
|
||||
@moduledoc """
|
||||
Extracts device identifiers from APRS packet data.
|
||||
Extracts device identifiers from APRS packet data using pattern matching.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
|
|
@ -8,35 +8,48 @@ defmodule Aprsme.DeviceParser do
|
|||
Returns the device identifier string or nil if not found.
|
||||
"""
|
||||
@spec extract_device_identifier(map()) :: String.t() | nil
|
||||
def extract_device_identifier(packet_data) do
|
||||
extract_from_destination(packet_data) ||
|
||||
extract_from_device_identifier(packet_data) ||
|
||||
extract_from_data_extended(packet_data)
|
||||
def extract_device_identifier(packet_data) when is_map(packet_data) do
|
||||
find_device_identifier(packet_data)
|
||||
end
|
||||
|
||||
defp extract_from_destination(packet_data) do
|
||||
get_field_value(packet_data, :destination) || get_field_value(packet_data, "destination")
|
||||
def extract_device_identifier(_), do: nil
|
||||
|
||||
# Pattern matching for different data structures
|
||||
defp find_device_identifier(%{device_identifier: id}) when is_binary(id) and id != "", do: id
|
||||
defp find_device_identifier(%{"device_identifier" => id}) when is_binary(id) and id != "", do: id
|
||||
defp find_device_identifier(%{destination: dest}) when is_binary(dest) and dest != "", do: dest
|
||||
defp find_device_identifier(%{"destination" => dest}) when is_binary(dest) and dest != "", do: dest
|
||||
|
||||
defp find_device_identifier(%{data_extended: data_ext} = packet) when is_map(data_ext) do
|
||||
extract_from_symbol_data(data_ext) || find_device_identifier(Map.delete(packet, :data_extended))
|
||||
end
|
||||
|
||||
defp extract_from_device_identifier(packet_data) do
|
||||
get_field_value(packet_data, :device_identifier) || get_field_value(packet_data, "device_identifier")
|
||||
defp find_device_identifier(%{"data_extended" => data_ext} = packet) when is_map(data_ext) do
|
||||
extract_from_symbol_data(data_ext) || find_device_identifier(Map.delete(packet, "data_extended"))
|
||||
end
|
||||
|
||||
defp get_field_value(packet_data, key) do
|
||||
if Map.has_key?(packet_data, key) do
|
||||
Map.get(packet_data, key)
|
||||
end
|
||||
defp find_device_identifier(_), do: nil
|
||||
|
||||
# Pattern matching for symbol data extraction
|
||||
defp extract_from_symbol_data(%{symbol_table_id: table, symbol_code: code})
|
||||
when is_binary(table) and is_binary(code) do
|
||||
"#{table}#{code}"
|
||||
end
|
||||
|
||||
defp extract_from_data_extended(packet_data) do
|
||||
data_extended = Map.get(packet_data, :data_extended) || %{}
|
||||
|
||||
# Try to extract from symbol information
|
||||
symbol_table_id = Map.get(data_extended, :symbol_table_id) || Map.get(data_extended, "symbol_table_id")
|
||||
symbol_code = Map.get(data_extended, :symbol_code) || Map.get(data_extended, "symbol_code")
|
||||
|
||||
if symbol_table_id && symbol_code do
|
||||
"#{symbol_table_id}#{symbol_code}"
|
||||
end
|
||||
defp extract_from_symbol_data(%{"symbol_table_id" => table, "symbol_code" => code})
|
||||
when is_binary(table) and is_binary(code) do
|
||||
"#{table}#{code}"
|
||||
end
|
||||
|
||||
defp extract_from_symbol_data(_), do: nil
|
||||
|
||||
@doc """
|
||||
Normalizes a device identifier to a canonical form.
|
||||
Handles both string and atom inputs with pattern matching.
|
||||
"""
|
||||
@spec normalize_device_identifier(String.t() | atom() | nil) :: String.t() | nil
|
||||
def normalize_device_identifier(nil), do: nil
|
||||
def normalize_device_identifier(id) when is_atom(id), do: Atom.to_string(id)
|
||||
def normalize_device_identifier(id) when is_binary(id), do: String.trim(id)
|
||||
def normalize_device_identifier(_), do: nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -46,76 +46,129 @@ defmodule Aprsme.PacketConsumer do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_events(
|
||||
events,
|
||||
_from,
|
||||
%{batch: batch, batch_size: batch_size, max_batch_size: max_batch_size, timer: timer} = state
|
||||
) do
|
||||
# More efficient: prepend events to batch (O(n) where n = length of events)
|
||||
new_batch = events ++ batch
|
||||
new_batch_length = length(new_batch)
|
||||
def handle_events(events, _from, state) do
|
||||
handle_batch_update(events, state)
|
||||
end
|
||||
|
||||
cond do
|
||||
new_batch_length >= max_batch_size ->
|
||||
# If batch exceeds maximum size, process immediately and drop excess
|
||||
{process_batch, drop_batch} = Enum.split(new_batch, max_batch_size)
|
||||
process_batch(process_batch)
|
||||
# Pattern matching for batch handling with optimized list operations
|
||||
defp handle_batch_update(events, %{batch: batch} = state) do
|
||||
# Optimize: Keep batch in reverse order for O(1) prepending
|
||||
# Only reverse when processing
|
||||
new_batch = Enum.reverse(events, batch)
|
||||
new_batch_length = batch_length(batch) + length(events)
|
||||
|
||||
# Log warning about dropping packets (sanitized)
|
||||
if length(drop_batch) > 0 do
|
||||
Logger.warning("Dropped #{length(drop_batch)} packets due to batch size limit",
|
||||
batch_info:
|
||||
LogSanitizer.log_data(
|
||||
dropped_count: length(drop_batch),
|
||||
processed_count: length(process_batch),
|
||||
reason: "batch_size_limit_exceeded"
|
||||
)
|
||||
)
|
||||
end
|
||||
handle_batch_by_size(new_batch, new_batch_length, state)
|
||||
end
|
||||
|
||||
# Cancel and restart timer
|
||||
if timer, do: Process.cancel_timer(timer)
|
||||
new_timer = Process.send_after(self(), :process_batch, state.batch_timeout)
|
||||
{:noreply, [], %{state | batch: [], timer: new_timer}}
|
||||
# Pattern matching for different batch size scenarios
|
||||
defp handle_batch_by_size(batch, length, %{max_batch_size: max} = state) when length >= max do
|
||||
handle_oversized_batch(batch, max, state)
|
||||
end
|
||||
|
||||
new_batch_length >= batch_size ->
|
||||
# Process the batch immediately
|
||||
process_batch(new_batch)
|
||||
defp handle_batch_by_size(batch, length, %{batch_size: size} = state) when length >= size do
|
||||
handle_full_batch(batch, state)
|
||||
end
|
||||
|
||||
# Cancel and restart timer
|
||||
if timer, do: Process.cancel_timer(timer)
|
||||
new_timer = Process.send_after(self(), :process_batch, state.batch_timeout)
|
||||
{:noreply, [], %{state | batch: [], timer: new_timer}}
|
||||
defp handle_batch_by_size(batch, _length, state) do
|
||||
handle_partial_batch(batch, state)
|
||||
end
|
||||
|
||||
true ->
|
||||
# Add to batch and wait for more
|
||||
{:noreply, [], %{state | batch: new_batch}}
|
||||
end
|
||||
# Handle oversized batch with pattern matching
|
||||
defp handle_oversized_batch(batch, max_size, state) do
|
||||
# Reverse once for processing
|
||||
reversed_batch = Enum.reverse(batch)
|
||||
{process_batch, drop_batch} = Enum.split(reversed_batch, max_size)
|
||||
|
||||
process_batch(process_batch)
|
||||
log_dropped_packets(drop_batch, process_batch)
|
||||
|
||||
new_state = reset_batch_timer(state)
|
||||
{:noreply, [], new_state}
|
||||
end
|
||||
|
||||
# Handle full batch
|
||||
defp handle_full_batch(batch, state) do
|
||||
# Reverse once for processing
|
||||
process_batch(Enum.reverse(batch))
|
||||
|
||||
new_state = reset_batch_timer(state)
|
||||
{:noreply, [], new_state}
|
||||
end
|
||||
|
||||
# Handle partial batch
|
||||
defp handle_partial_batch(batch, state) do
|
||||
# Keep batch in reverse order
|
||||
{:noreply, [], %{state | batch: batch}}
|
||||
end
|
||||
|
||||
# Helper functions with pattern matching
|
||||
defp reset_batch_timer(%{timer: nil} = state) do
|
||||
new_timer = Process.send_after(self(), :process_batch, state.batch_timeout)
|
||||
%{state | batch: [], timer: new_timer}
|
||||
end
|
||||
|
||||
defp reset_batch_timer(%{timer: timer} = state) do
|
||||
Process.cancel_timer(timer)
|
||||
new_timer = Process.send_after(self(), :process_batch, state.batch_timeout)
|
||||
%{state | batch: [], timer: new_timer}
|
||||
end
|
||||
|
||||
# Efficient batch length calculation (cached if possible)
|
||||
defp batch_length([]), do: 0
|
||||
defp batch_length(batch), do: length(batch)
|
||||
|
||||
# Pattern matching for logging
|
||||
defp log_dropped_packets([], _), do: :ok
|
||||
|
||||
defp log_dropped_packets(dropped, processed) do
|
||||
Logger.warning("Dropped #{length(dropped)} packets due to batch size limit",
|
||||
batch_info:
|
||||
LogSanitizer.log_data(
|
||||
dropped_count: length(dropped),
|
||||
processed_count: length(processed),
|
||||
reason: "batch_size_limit_exceeded"
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:process_batch, %{batch: batch, batch_timeout: timeout, max_batch_size: max_batch_size} = state) do
|
||||
if length(batch) > 0 do
|
||||
# Check if batch size is concerning
|
||||
if length(batch) > max_batch_size * 0.8 do
|
||||
Logger.warning("Batch size approaching limit",
|
||||
batch_status:
|
||||
LogSanitizer.log_data(
|
||||
current_size: length(batch),
|
||||
max_size: max_batch_size,
|
||||
utilization_percent: trunc(length(batch) / max_batch_size * 100)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
process_batch(batch)
|
||||
end
|
||||
|
||||
# Start a new timer
|
||||
timer = Process.send_after(self(), :process_batch, timeout)
|
||||
{:noreply, [], %{state | batch: [], timer: timer}}
|
||||
# Pattern matching for empty batch
|
||||
def handle_info(:process_batch, %{batch: []} = state) do
|
||||
# Just restart the timer for empty batch
|
||||
new_state = start_batch_timer(state)
|
||||
{:noreply, [], new_state}
|
||||
end
|
||||
|
||||
# Pattern matching for non-empty batch
|
||||
def handle_info(:process_batch, %{batch: batch} = state) when is_list(batch) do
|
||||
check_batch_utilization(batch, state.max_batch_size)
|
||||
# Remember to reverse the batch since we keep it in reverse order
|
||||
process_batch(Enum.reverse(batch))
|
||||
|
||||
new_state = start_batch_timer(%{state | batch: []})
|
||||
{:noreply, [], new_state}
|
||||
end
|
||||
|
||||
# Helper to start batch timer
|
||||
defp start_batch_timer(%{batch_timeout: timeout} = state) do
|
||||
timer = Process.send_after(self(), :process_batch, timeout)
|
||||
%{state | timer: timer}
|
||||
end
|
||||
|
||||
# Pattern matching for batch utilization check
|
||||
defp check_batch_utilization(batch, max_size) when length(batch) > max_size * 0.8 do
|
||||
Logger.warning("Batch size approaching limit",
|
||||
batch_status:
|
||||
LogSanitizer.log_data(
|
||||
current_size: length(batch),
|
||||
max_size: max_size,
|
||||
utilization_percent: trunc(length(batch) / max_size * 100)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp check_batch_utilization(_, _), do: :ok
|
||||
|
||||
defp process_batch(packets) do
|
||||
require Logger
|
||||
|
||||
|
|
|
|||
|
|
@ -26,44 +26,66 @@ defmodule Aprsme.Packets do
|
|||
def store_packet(packet_data) do
|
||||
require Logger
|
||||
|
||||
try do
|
||||
# First sanitize the input data
|
||||
sanitized_packet_data = Aprsme.EncodingUtils.sanitize_packet(packet_data)
|
||||
with {:ok, sanitized_data} <- sanitize_packet_data(packet_data),
|
||||
{:ok, packet_attrs} <- build_packet_attrs(sanitized_data),
|
||||
{:ok, packet} <- insert_packet(packet_attrs, packet_data) do
|
||||
{:ok, packet}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Failed to store packet for #{inspect(packet_data[:sender])}: #{inspect(reason)}")
|
||||
store_bad_packet(packet_data, reason)
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
packet_attrs =
|
||||
sanitized_packet_data
|
||||
|> Packet.extract_additional_data(
|
||||
sanitized_packet_data[:raw_packet] || sanitized_packet_data["raw_packet"] || ""
|
||||
)
|
||||
|> normalize_packet_attrs()
|
||||
|> set_received_at()
|
||||
|> patch_lat_lon_from_data_extended()
|
||||
|> then(fn attrs ->
|
||||
{lat, lon} = extract_position(attrs)
|
||||
set_lat_lon(attrs, lat, lon)
|
||||
end)
|
||||
|> normalize_ssid()
|
||||
|> then(fn attrs ->
|
||||
device_identifier = Aprsme.DeviceParser.extract_device_identifier(packet_data)
|
||||
canonical_identifier = get_canonical_device_identifier(device_identifier)
|
||||
Map.put(attrs, :device_identifier, canonical_identifier)
|
||||
end)
|
||||
# Pattern matching for sanitization
|
||||
defp sanitize_packet_data(packet_data) do
|
||||
{:ok, Aprsme.EncodingUtils.sanitize_packet(packet_data)}
|
||||
rescue
|
||||
error -> {:error, {:sanitization_failed, error}}
|
||||
end
|
||||
|
||||
# require Logger
|
||||
# Logger.debug("Sanitized packet_attrs before insert: #{inspect(packet_attrs)}")
|
||||
# Set device_identifier to parsed value, fallback to destination if nil
|
||||
parsed_device_id = Aprsme.DeviceParser.extract_device_identifier(packet_data)
|
||||
device_id = parsed_device_id || Map.get(packet_attrs, :destination)
|
||||
packet_attrs = Map.put(packet_attrs, :device_identifier, device_id)
|
||||
# Build packet attributes with pipeline
|
||||
defp build_packet_attrs(sanitized_data) do
|
||||
raw_packet = get_raw_packet(sanitized_data)
|
||||
|
||||
# Logger.debug("Inserting packet with device_identifier=#{inspect(device_id)}, destination=#{inspect(Map.get(packet_attrs, :destination))}")
|
||||
insert_packet(packet_attrs, packet_data)
|
||||
rescue
|
||||
error ->
|
||||
Logger.error("Exception in store_packet for #{inspect(packet_data[:sender])}: #{inspect(error)}")
|
||||
attrs =
|
||||
sanitized_data
|
||||
|> Packet.extract_additional_data(raw_packet)
|
||||
|> normalize_packet_attrs()
|
||||
|> set_received_at()
|
||||
|> patch_lat_lon_from_data_extended()
|
||||
|> apply_position_data()
|
||||
|> normalize_ssid()
|
||||
|> apply_device_identifier(sanitized_data)
|
||||
|
||||
store_bad_packet(packet_data, error)
|
||||
{:error, :storage_exception}
|
||||
{:ok, attrs}
|
||||
rescue
|
||||
error -> {:error, {:build_attrs_failed, error}}
|
||||
end
|
||||
|
||||
# Pattern matching for raw packet extraction
|
||||
defp get_raw_packet(%{raw_packet: raw}) when is_binary(raw), do: raw
|
||||
defp get_raw_packet(%{"raw_packet" => raw}) when is_binary(raw), do: raw
|
||||
defp get_raw_packet(_), do: ""
|
||||
|
||||
# Apply position data using pattern matching
|
||||
defp apply_position_data(attrs) do
|
||||
case extract_position(attrs) do
|
||||
{nil, nil} -> attrs
|
||||
{lat, lon} -> set_lat_lon(attrs, lat, lon)
|
||||
end
|
||||
end
|
||||
|
||||
# Apply device identifier with pattern matching
|
||||
defp apply_device_identifier(attrs, original_data) do
|
||||
case Aprsme.DeviceParser.extract_device_identifier(original_data) do
|
||||
nil ->
|
||||
# Fallback to destination
|
||||
Map.put(attrs, :device_identifier, Map.get(attrs, :destination))
|
||||
|
||||
device_id ->
|
||||
Map.put(attrs, :device_identifier, get_canonical_device_identifier(device_id))
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -93,47 +115,73 @@ defmodule Aprsme.Packets do
|
|||
Map.put(attrs, :received_at, current_time)
|
||||
end
|
||||
|
||||
defp patch_lat_lon_from_data_extended(attrs) do
|
||||
case attrs[:data_extended] do
|
||||
%{} = ext ->
|
||||
ext_map = if Map.has_key?(ext, :__struct__), do: Map.from_struct(ext), else: ext
|
||||
lat = extract_lat_from_ext_map(ext_map)
|
||||
lon = extract_lon_from_ext_map(ext_map)
|
||||
latd = to_decimal(lat)
|
||||
lond = to_decimal(lon)
|
||||
# Pattern matching with guards for cleaner code
|
||||
defp patch_lat_lon_from_data_extended(%{data_extended: %{} = ext} = attrs) do
|
||||
ext_map = normalize_to_map(ext)
|
||||
|
||||
if not is_nil(latd) and not is_nil(lond) do
|
||||
attrs
|
||||
|> Map.put(:lat, latd)
|
||||
|> Map.put(:lon, lond)
|
||||
else
|
||||
attrs
|
||||
end
|
||||
|
||||
_ ->
|
||||
attrs
|
||||
with lat when not is_nil(lat) <- extract_lat_from_ext_map(ext_map),
|
||||
lon when not is_nil(lon) <- extract_lon_from_ext_map(ext_map),
|
||||
{:ok, lat_decimal} <- to_decimal_safe(lat),
|
||||
{:ok, lon_decimal} <- to_decimal_safe(lon) do
|
||||
attrs
|
||||
|> Map.put(:lat, lat_decimal)
|
||||
|> Map.put(:lon, lon_decimal)
|
||||
else
|
||||
_ -> attrs
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_lat_from_ext_map(ext_map) when is_map(ext_map) and not is_struct(ext_map) do
|
||||
ext_map[:latitude] || ext_map["latitude"] ||
|
||||
(Map.has_key?(ext_map, :position) &&
|
||||
(ext_map[:position][:latitude] || ext_map[:position]["latitude"])) ||
|
||||
(Map.has_key?(ext_map, "position") &&
|
||||
(ext_map["position"][:latitude] || ext_map["position"]["latitude"]))
|
||||
defp patch_lat_lon_from_data_extended(attrs), do: attrs
|
||||
|
||||
# Helper to normalize struct or map
|
||||
defp normalize_to_map(%{__struct__: _} = struct), do: Map.from_struct(struct)
|
||||
defp normalize_to_map(map) when is_map(map), do: map
|
||||
|
||||
# Safe decimal conversion with pattern matching
|
||||
defp to_decimal_safe(nil), do: {:error, :nil_value}
|
||||
|
||||
defp to_decimal_safe(value) do
|
||||
case to_decimal(value) do
|
||||
nil -> {:error, :conversion_failed}
|
||||
decimal -> {:ok, decimal}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_lat_from_ext_map(_), do: nil
|
||||
|
||||
defp extract_lon_from_ext_map(ext_map) when is_map(ext_map) and not is_struct(ext_map) do
|
||||
ext_map[:longitude] || ext_map["longitude"] ||
|
||||
(Map.has_key?(ext_map, :position) &&
|
||||
(ext_map[:position][:longitude] || ext_map[:position]["longitude"])) ||
|
||||
(Map.has_key?(ext_map, "position") &&
|
||||
(ext_map["position"][:longitude] || ext_map["position"]["longitude"]))
|
||||
# Pattern matching for coordinate extraction
|
||||
defp extract_lat_from_ext_map(ext_map) do
|
||||
extract_coordinate(ext_map, :latitude)
|
||||
end
|
||||
|
||||
defp extract_lon_from_ext_map(_), do: nil
|
||||
defp extract_lon_from_ext_map(ext_map) do
|
||||
extract_coordinate(ext_map, :longitude)
|
||||
end
|
||||
|
||||
# Unified coordinate extraction with pattern matching
|
||||
defp extract_coordinate(%{} = map, coord_type) when coord_type in [:latitude, :longitude] do
|
||||
coord_string = Atom.to_string(coord_type)
|
||||
|
||||
find_coordinate_value(map, coord_type) || find_coordinate_value(map, coord_string)
|
||||
end
|
||||
|
||||
defp extract_coordinate(_, _), do: nil
|
||||
|
||||
# Pattern matching for direct coordinate access
|
||||
defp find_coordinate_value(%{latitude: lat}, :latitude) when not is_nil(lat), do: lat
|
||||
defp find_coordinate_value(%{longitude: lon}, :longitude) when not is_nil(lon), do: lon
|
||||
defp find_coordinate_value(%{"latitude" => lat}, "latitude") when not is_nil(lat), do: lat
|
||||
defp find_coordinate_value(%{"longitude" => lon}, "longitude") when not is_nil(lon), do: lon
|
||||
|
||||
# Pattern matching for nested position access
|
||||
defp find_coordinate_value(%{position: %{latitude: lat}}, :latitude) when not is_nil(lat), do: lat
|
||||
defp find_coordinate_value(%{position: %{longitude: lon}}, :longitude) when not is_nil(lon), do: lon
|
||||
defp find_coordinate_value(%{position: %{"latitude" => lat}}, :latitude) when not is_nil(lat), do: lat
|
||||
defp find_coordinate_value(%{position: %{"longitude" => lon}}, :longitude) when not is_nil(lon), do: lon
|
||||
defp find_coordinate_value(%{"position" => %{latitude: lat}}, "latitude") when not is_nil(lat), do: lat
|
||||
defp find_coordinate_value(%{"position" => %{longitude: lon}}, "longitude") when not is_nil(lon), do: lon
|
||||
defp find_coordinate_value(%{"position" => %{"latitude" => lat}}, "latitude") when not is_nil(lat), do: lat
|
||||
defp find_coordinate_value(%{"position" => %{"longitude" => lon}}, "longitude") when not is_nil(lon), do: lon
|
||||
|
||||
defp find_coordinate_value(_, _), do: nil
|
||||
|
||||
defp set_lat_lon(attrs, lat, lon) do
|
||||
# Optimize: avoid creating anonymous function on each call
|
||||
|
|
@ -157,12 +205,11 @@ defmodule Aprsme.Packets do
|
|||
|
||||
defp round_coordinate(_), do: nil
|
||||
|
||||
defp normalize_ssid(attrs) do
|
||||
case Map.get(attrs, :ssid) do
|
||||
nil -> attrs
|
||||
ssid -> Map.put(attrs, :ssid, to_string(ssid))
|
||||
end
|
||||
end
|
||||
# Pattern matching for SSID normalization
|
||||
defp normalize_ssid(%{ssid: nil} = attrs), do: attrs
|
||||
defp normalize_ssid(%{ssid: ssid} = attrs) when is_binary(ssid), do: attrs
|
||||
defp normalize_ssid(%{ssid: ssid} = attrs), do: Map.put(attrs, :ssid, to_string(ssid))
|
||||
defp normalize_ssid(attrs), do: attrs
|
||||
|
||||
defp insert_packet(attrs, packet_data) do
|
||||
# Ensure data_extended is properly sanitized before insertion
|
||||
|
|
@ -349,13 +396,17 @@ defmodule Aprsme.Packets do
|
|||
defp apply_direction(value, direction, negative_direction) when direction == negative_direction, do: -value
|
||||
defp apply_direction(value, _direction, _negative_direction), do: value
|
||||
|
||||
defp get_canonical_device_identifier(device_identifier) do
|
||||
# Pattern matching for canonical device identifier lookup
|
||||
defp get_canonical_device_identifier(device_identifier) when is_binary(device_identifier) do
|
||||
case Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier) do
|
||||
%{identifier: canonical_id} -> canonical_id
|
||||
nil -> device_identifier
|
||||
%{identifier: canonical_id} when is_binary(canonical_id) -> canonical_id
|
||||
_ -> device_identifier
|
||||
end
|
||||
end
|
||||
|
||||
defp get_canonical_device_identifier(nil), do: nil
|
||||
defp get_canonical_device_identifier(device_identifier), do: to_string(device_identifier)
|
||||
|
||||
@doc """
|
||||
Gets packets for replay.
|
||||
|
||||
|
|
|
|||
365
lib/aprsme_web/live/map_live/components.ex
Normal file
365
lib/aprsme_web/live/map_live/components.ex
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
defmodule AprsmeWeb.MapLive.Components do
|
||||
@moduledoc """
|
||||
Reusable function components for the Map LiveView.
|
||||
Extracted to improve maintainability and reduce the main LiveView module size.
|
||||
"""
|
||||
use AprsmeWeb, :html
|
||||
|
||||
attr :flash, :map, default: %{}
|
||||
attr :slideover_open, :boolean, default: false
|
||||
attr :rest, :global
|
||||
|
||||
def map_container(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="aprs-map"
|
||||
phx-update="ignore"
|
||||
phx-hook="LeafletMap"
|
||||
class={[
|
||||
"slideover-open": @slideover_open,
|
||||
"slideover-closed": !@slideover_open
|
||||
]}
|
||||
>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :slideover_open, :boolean, required: true
|
||||
attr :loading, :boolean, default: false
|
||||
attr :connection_status, :string, default: "pending"
|
||||
attr :packets, :list, default: []
|
||||
attr :show_all_packets, :boolean, default: true
|
||||
attr :tracked_callsign, :string, default: ""
|
||||
attr :tracked_callsign_latest_packet, :map, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def slideover_panel(assigns) do
|
||||
~H"""
|
||||
<div class={[
|
||||
"slideover-panel",
|
||||
"slideover-open": @slideover_open,
|
||||
"slideover-closed": !@slideover_open
|
||||
]}>
|
||||
<.slideover_header {assigns} />
|
||||
<.slideover_content {assigns} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp slideover_header(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center justify-between p-4 border-b bg-gray-50">
|
||||
<h2 class="text-lg font-semibold text-gray-900">APRS Packets</h2>
|
||||
<button
|
||||
phx-click="toggle_slideover"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp slideover_content(assigns) do
|
||||
~H"""
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
<%= if @loading do %>
|
||||
<.loading_indicator />
|
||||
<% else %>
|
||||
<.packet_controls {assigns} />
|
||||
<.packet_list {assigns} />
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp loading_indicator(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
<span class="ml-3 text-lg text-gray-700">Loading packets...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp packet_controls(assigns) do
|
||||
~H"""
|
||||
<div class="p-4 border-b space-y-3">
|
||||
<.connection_indicator connection_status={@connection_status} />
|
||||
<.view_toggle show_all_packets={@show_all_packets} />
|
||||
<.callsign_filter
|
||||
tracked_callsign={@tracked_callsign}
|
||||
tracked_callsign_latest_packet={@tracked_callsign_latest_packet}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp connection_indicator(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class={[
|
||||
"h-3 w-3 rounded-full",
|
||||
"bg-green-500": @connection_status == "connected",
|
||||
"bg-yellow-500": @connection_status == "connecting",
|
||||
"bg-red-500": @connection_status == "disconnected",
|
||||
"bg-gray-400": @connection_status == "pending"
|
||||
]}>
|
||||
</div>
|
||||
<span class="text-sm text-gray-700">
|
||||
<%= case @connection_status do %>
|
||||
<% "connected" -> %>
|
||||
Connected to APRS-IS
|
||||
<% "connecting" -> %>
|
||||
Connecting to APRS-IS...
|
||||
<% "disconnected" -> %>
|
||||
Disconnected from APRS-IS
|
||||
<% _ -> %>
|
||||
Initializing...
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp view_toggle(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center space-x-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="view_mode"
|
||||
value="all"
|
||||
checked={@show_all_packets}
|
||||
phx-click="toggle_view_mode"
|
||||
phx-value-mode="all"
|
||||
class="text-blue-600"
|
||||
/>
|
||||
<span class="ml-2 text-sm">All Packets</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="view_mode"
|
||||
value="with_position"
|
||||
checked={!@show_all_packets}
|
||||
phx-click="toggle_view_mode"
|
||||
phx-value-mode="with_position"
|
||||
class="text-blue-600"
|
||||
/>
|
||||
<span class="ml-2 text-sm">With Position</span>
|
||||
</label>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp callsign_filter(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<form phx-submit="filter_callsign" class="relative">
|
||||
<input
|
||||
type="text"
|
||||
name="callsign"
|
||||
value={@tracked_callsign}
|
||||
placeholder="Filter by callsign..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md pr-8 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
phx-debounce="300"
|
||||
phx-change="filter_callsign"
|
||||
/>
|
||||
<%= if @tracked_callsign != "" do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="clear_callsign_filter"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-4 h-4" />
|
||||
</button>
|
||||
<% end %>
|
||||
</form>
|
||||
<%= if @tracked_callsign_latest_packet do %>
|
||||
<div class="mt-2 text-xs text-gray-600">
|
||||
Last seen: {format_time_ago(@tracked_callsign_latest_packet.received_at)}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp packet_list(assigns) do
|
||||
~H"""
|
||||
<div id="packets" class="flex-1 overflow-y-auto p-4 space-y-3" phx-update="stream">
|
||||
<%= if @packets == [] do %>
|
||||
<div class="text-center text-gray-500 py-8">
|
||||
No packets received yet...
|
||||
</div>
|
||||
<% else %>
|
||||
<div :for={{id, packet} <- @packets} id={id}>
|
||||
<.packet_card packet={packet} />
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp packet_card(assigns) do
|
||||
~H"""
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4 hover:border-blue-300 transition-colors">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-gray-900">
|
||||
{@packet.sender}
|
||||
<%= if @packet.ssid && @packet.ssid != "0" do %>
|
||||
<span class="text-gray-500">-{@packet.ssid}</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= if @packet.has_position do %>
|
||||
<div class="text-sm text-gray-600 mt-1">
|
||||
📍 {format_coordinates(@packet.lat, @packet.lon)}
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @packet.comment do %>
|
||||
<div class="text-sm text-gray-700 mt-2">
|
||||
{@packet.comment}
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
via {@packet.path || "direct"} • {format_time_ago(@packet.received_at)}
|
||||
</div>
|
||||
</div>
|
||||
<%= if @packet.has_position do %>
|
||||
<button
|
||||
phx-click="center_on_packet"
|
||||
phx-value-lat={@packet.lat}
|
||||
phx-value-lon={@packet.lon}
|
||||
phx-value-sender={@packet.sender}
|
||||
class="ml-2 p-2 text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
title="Center map on this location"
|
||||
>
|
||||
<.icon name="hero-map-pin" class="w-5 h-5" />
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
defp format_time_ago(datetime) do
|
||||
# Implement time ago formatting
|
||||
case DateTime.diff(DateTime.utc_now(), datetime, :second) do
|
||||
seconds when seconds < 60 -> "#{seconds}s ago"
|
||||
seconds when seconds < 3600 -> "#{div(seconds, 60)}m ago"
|
||||
seconds when seconds < 86_400 -> "#{div(seconds, 3600)}h ago"
|
||||
seconds -> "#{div(seconds, 86_400)}d ago"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_coordinates(lat, lon) when is_number(lat) and is_number(lon) do
|
||||
"#{Float.round(lat * 1.0, 4)}, #{Float.round(lon * 1.0, 4)}"
|
||||
end
|
||||
|
||||
defp format_coordinates(_, _), do: "Unknown location"
|
||||
|
||||
# Style component for better organization
|
||||
def map_styles(assigns) do
|
||||
~H"""
|
||||
<style>
|
||||
#aprs-map {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 100vh;
|
||||
z-index: 1;
|
||||
transition: right 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Desktop styles */
|
||||
@media (min-width: 1024px) {
|
||||
#aprs-map.slideover-open {
|
||||
right: 352px;
|
||||
}
|
||||
|
||||
#aprs-map.slideover-closed {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slideover panel base styles */
|
||||
.slideover-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 352px;
|
||||
background: white;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slideover-panel.slideover-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.slideover-panel.slideover-closed {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 1023px) {
|
||||
#aprs-map {
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
.slideover-panel {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading spinner animation */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for packet list */
|
||||
#packets::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
#packets::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
#packets::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#packets::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -5,9 +5,11 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
use AprsmeWeb, :live_view
|
||||
|
||||
import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1]
|
||||
import AprsmeWeb.MapLive.Components
|
||||
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
|
||||
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_patch: 2, put_flash: 3]
|
||||
|
||||
# Import the new components module
|
||||
alias Aprsme.Packets
|
||||
alias Aprsme.Packets.Clustering
|
||||
alias AprsmeWeb.Endpoint
|
||||
|
|
@ -995,8 +997,61 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<%!-- All vendor libraries are now loaded from vendor.js bundle --%>
|
||||
<.map_styles />
|
||||
|
||||
<.map_container slideover_open={@slideover_open} />
|
||||
|
||||
<.locate_button />
|
||||
|
||||
<.slideover_panel
|
||||
slideover_open={@slideover_open}
|
||||
loading={@loading}
|
||||
connection_status={@connection_status}
|
||||
packets={@streams.packets}
|
||||
show_all_packets={@show_all_packets}
|
||||
tracked_callsign={@tracked_callsign}
|
||||
tracked_callsign_latest_packet={@tracked_callsign_latest_packet}
|
||||
/>
|
||||
|
||||
<.toggle_button slideover_open={@slideover_open} />
|
||||
|
||||
<.bottom_controls {assigns} />
|
||||
"""
|
||||
end
|
||||
|
||||
# Additional component functions that weren't extracted yet
|
||||
defp locate_button(assigns) do
|
||||
~H"""
|
||||
<button class="locate-button" phx-click="locate_me" title="Locate me">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp toggle_button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
phx-click="toggle_slideover"
|
||||
class={[
|
||||
"fixed right-4 top-4 z-40 bg-white rounded-lg shadow-lg p-3",
|
||||
"hover:bg-gray-50 transition-colors lg:hidden",
|
||||
hidden: @slideover_open
|
||||
]}
|
||||
>
|
||||
<.icon name="hero-bars-3" class="w-6 h-6 text-gray-700" />
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp bottom_controls(assigns) do
|
||||
~H"""
|
||||
<%!-- Existing bottom controls code will go here --%>
|
||||
<style>
|
||||
#aprs-map {
|
||||
position: absolute;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue