Compare commits

...

10 commits

Author SHA1 Message Date
7e1498bf4e
perf: batch high-frequency LiveView updates via socket.private
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 50s
Buffer incoming packets in socket.private (no rerender) and flush to
assigns/stream on a 150ms timer, preventing client-side morphdom from
being overwhelmed during traffic bursts. Applies to the global packet
feed (stream) and per-callsign detail view (assigns).
2026-07-22 11:23:09 -05:00
Graham McInitre
0e322a2403 docs: add structured documentation covering architecture, contexts, web, infra, and dev setup 2026-07-21 11:01:27 -05:00
Graham McInitre
1006fdaefc refactor: remove dead code, duplication, and over-abstractions
- Delete dead config keys and test file (typo'd aprsme_is_*, vacuous disable test)
- Remove duplicate function clauses and identical function pairs
- Rename misleading one_hour_ago variable to one_day_ago
- Strip stale Oban comment
- Remove TestHelpers time function duplicates
- Inline Aprsme.Schema module (only 1 caller)
- Deduplicate prod esbuild config
- Thin SymbolRenderer delegations, inline TimeUtils wrappers
- Remove redundant DeploymentNotifier GenServer polling
- Replace random fallback in get_callsign_key with sentinel
2026-07-21 10:36:49 -05:00
Graham McInitre
243a98df77 fix: security hardening and query performance improvements
- Session cookie: add encryption_salt and secure flag to endpoint
- CSP: remove unsafe-eval from script-src directive
- /metrics: wrap PromEx endpoint behind rate-limited pipeline
- LiveView signing_salt: move to env var in production config
- LiveView performance: defer heavy aggregation queries past initial mount
- SQL: replace correlated subquery with DISTINCT ON in get_heard_by_stations
2026-07-21 10:13:30 -05:00
Graham McInitre
b88eb9e584 chore: update mix deps (circular_buffer, elixir_make, lazy_html) 2026-07-21 08:55:42 -05:00
Graham McInitre
bb73c17ce6 chore: commit shared .envrc, gitignore .envrc.local for secrets 2026-07-21 08:53:35 -05:00
77d2a6ca30
updat edeps 2026-07-17 16:42:53 -05:00
281aef095c
fix: resolve all credo warnings and pre-existing dialyzer errors
- Move require Logger to module top level in SpatialPubSub
- Replace weak is_list assertions with stronger checks
- Remove redundant 'is a list' test in PromEx tests
- Replace apply/3 with direct function call in packet_replay test
- Fix unmatched_return in weather_cache.ex and callsign_view.ex
2026-07-17 16:41:15 -05:00
7bdf21fb53
deps: ecto_sql 3.13→3.14, prom_ex 1.11→1.12, decimal 2.4→3.1, ecto 3.13→3.14; bump test pool for sandbox 2026-07-08 10:46:05 -05:00
62bbeaadb3
deps: update phoenix 1.8.8→1.8.9, swoosh 1.26.2→1.26.3; cleanup test assertions 2026-07-08 10:28:18 -05:00
66 changed files with 1833 additions and 514 deletions

3
.envrc Normal file
View file

@ -0,0 +1,3 @@
# shellcheck shell=bash
use flake
source_env .envrc.local

12
.gitignore vendored
View file

@ -59,5 +59,15 @@ gleam@@compile.erl
# Expert language server directory
/.expert
.envrc
.envrc.local
.worktrees/
# Nix development environment state
.nix-mix/
.nix-hex/
.nix-postgres/
.nix-postgres-version
.nix-services-started
.direnv/
# Generated by pre-commit-hooks.nix (symlink into /nix/store)
.pre-commit-config.yaml

View file

@ -1,2 +1,2 @@
erlang 29.0.1
erlang 29.0.3
elixir 1.20.2-otp-29

View file

@ -31,7 +31,7 @@ config :aprsme, AprsmeWeb.Endpoint,
layout: false
],
pubsub_server: Aprsme.PubSub,
live_view: [signing_salt: "ees098qG"]
live_view: [signing_salt: "dev-signing-salt-local-only"]
# Configure Gettext with supported locales from AprsmeWeb.Gettext module
config :aprsme, AprsmeWeb.Gettext,

View file

@ -15,15 +15,6 @@ config :aprsme, AprsmeWeb.Endpoint,
adapter: Bandit.PhoenixAdapter,
http: [ip: {0, 0, 0, 0}, port: 4000]
config :esbuild,
version: "0.25.4",
default: [
args:
~w(js/app.ts --bundle --target=es2020 --outdir=../priv/static/assets --loader:.css=css --loader:.png=file --loader:.svg=file --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
# Do not print debug messages in production
# of environment variables, is done on config/runtime.exs.
config :logger, level: :info

View file

@ -43,6 +43,20 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret
"""
live_view_signing_salt =
System.get_env("LIVE_VIEW_SIGNING_SALT") ||
raise """
environment variable LIVE_VIEW_SIGNING_SALT is missing.
You can generate one by calling: mix phx.gen.secret 32
"""
encryption_salt =
System.get_env("ENCRYPTION_SALT") ||
raise """
environment variable ENCRYPTION_SALT is missing.
You can generate one by calling: mix phx.gen.secret 32
"""
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
@ -99,6 +113,8 @@ if config_env() == :prod do
port: port
],
secret_key_base: secret_key_base,
live_view: [signing_salt: live_view_signing_salt],
session_options: [encryption_salt: encryption_salt, secure: true],
server: true,
check_origin: [
"https://#{host}",

View file

@ -10,7 +10,7 @@ pool_size =
if System.get_env("MIX_TEST_COVERAGE") do
2
else
System.schedulers_online() * 4
System.schedulers_online() * 16
end
# In test we don't send emails.

41
docs/README.md Normal file
View file

@ -0,0 +1,41 @@
# aprs.me Documentation
APRS packet visualization and tracking platform built with Phoenix LiveView + PostGIS.
## Architecture
- [Architecture Overview](architecture/overview.md) — System components, data flow, and design decisions
- [Ingestion Pipeline](architecture/ingestion-pipeline.md) — APRS-IS → GenStage → Postgres → PubSub broadcast
## Domain Contexts
- [Accounts](contexts/accounts.md) — User registration, authentication, session management
- [Packets](contexts/packets.md) — Packet storage, querying, replay, weather, and spatial search
- [Devices](contexts/devices.md) — APRS device identification and registry
## Web Layer
- [LiveView Map](web/liveview-map.md) — MapLive architecture, real-time streaming, components
- [LiveView Pages](web/liveview-pages.md) — Packet streams, weather, status, info, auth pages
- [REST API](web/api-rest.md) — API v1 endpoints (callsign lookup, weather nearby)
- [Mobile WebSocket](web/channels.md) — MobileChannel protocol for iOS/Android apps
## Infrastructure
- [Deployment](infrastructure/deployment.md) — Kubernetes (ArgoCD), Fly.io, Docker, Nix dev shell
- [Observability](infrastructure/observability.md) — PromEx metrics, error tracking, telemetry
## Development
- [Setup Guide](development/setup.md) — Local dev environment, required services, tooling
## Features & Plans
- [Tracked Callsign Behavior](tracked-callsign-behavior.md)
- [Trail Line Visualization](features/trail-line-visualization.md)
- [Plans](plans/) — Design documents and implementation plans
- [Improvement Todos](improvement-todos.md)
## API
- [Mobile API Reference](mobile-api.md) — WebSocket message format for mobile clients

View file

@ -0,0 +1,66 @@
# Ingestion Pipeline
The packet ingestion pipeline moves raw APRS data from the APRS-IS network into Postgres and broadcasts to connected clients.
## Components
### Aprsme.Is (TCP Connection)
- **File:** `lib/aprsme/is/is.ex`
- **Type:** GenServer
- **Role:** Maintains TCP connection to APRS-IS, authenticates with callsign + passcode, receives raw APRS frames
- **Parsing:** Uses `aprs.parse/1` (external Gleam library) to decode APRS frames
- **Filtering:** Configurable APRS-IS filter (by callsign, area, distance) via `Aprsme.Is.LoginParams`
- **Backpressure:** Responds to `{:backpressure, :activate | :deactivate}` signals from `PacketProducer` — sets TCP socket to `:passive` mode when buffer is full
### PacketProducer (GenStage)
- **File:** `lib/aprsme/packet_producer.ex`
- **Type:** GenStage producer
- **Buffer:** Erlang `:queue` with water-mark backpressure
- **High water** (>80% capacity): signals `Aprsme.Is` to activate backpressure
- **Low water** (<30% capacity): signals `Aprsme.Is` to deactivate backpressure
- **Demand-driven:** Only emits packets when consumers are ready
### PacketConsumer (GenStage Consumer)
- **File:** `lib/aprsme/packet_consumer.ex`
- **Type:** GenStage consumer (pooled via `PacketConsumerPool`)
- **Processing pipeline per packet:**
1. Sanitize — `PacketSanitizer.sanitize_packet/1` (overflow prevention)
2. Normalize — `EncodingUtils` conversions (UTF-8, latin1, floats, decimals)
3. Extract position — lat/lon → PostGIS geometry
4. Extract device — `DeviceParser.extract_device_id/1`
5. Filter fields — `PacketFieldWhitelist` trims to schema columns
6. Insert — `Repo.insert_all` with `on_conflict: :nothing` (idempotent)
7. Broadcast — async dispatch to PubSub systems
### Broadcasting After Insert
After successful insert, each packet is broadcast to three PubSub layers:
1. **StreamingPacketsPubSub** — ETS-based, clients subscribe with bounds
2. **SpatialPubSub** — Grid-indexed (1° cells), viewport-filtered delivery
3. **Phoenix.PubSub** — Legacy per-topic broadcasts:
- `"postgres:aprsme_packets"` — global packet stream
- `"packets:#{callsign}"` — per-callsign updates
- `"weather:#{callsign}"` — per-weather-station updates
Broadcast execution is async via `BroadcastTaskSupervisor`.
## Error Handling
Packets that fail parsing or validation are stored in `badpackets` table via `Packets.store_bad_packet/2` with error type/message, viewable at `/badpackets` page.
## Configuration
- APRS-IS server, callsign, passcode set via application config
- Consumer pool size configurable per environment
- Retention period configurable via `CleanupScheduler`
## Performance Notes
- `PreparedQueries` module caches frequently-used query plans
- `InsertOptimizer` batch-tunes insert operations
- `PacketConsumer` uses `insert_all` (not individual inserts) for throughput
- Daily table partitioning via `PartitionManager` keeps index sizes manageable

View file

@ -0,0 +1,92 @@
# Architecture Overview
## System Purpose
Real-time visualization of APRS (Automatic Packet Reporting System) packets on an interactive map. Tracks amateur radio stations, weather data, and station telemetry via APRS-IS network feed.
## Technology Stack
| Layer | Technology |
|---|---|
| Language | Elixir 1.17+ |
| Web Framework | Phoenix 1.8 + LiveView 1.2 |
| Database | PostgreSQL + PostGIS |
| APRS Parsing | `aprs` (Gleam BEAM library) |
| Map Client | Leaflet (lazy-loaded) |
| Charts | Chart.js (lazy-loaded) |
| Metrics | PromEx + Telemetry |
| Deployment | Kubernetes (ArgoCD), Fly.io, Docker |
## High-Level Data Flow
```
APRS-IS Network (TCP)
Aprsme.Is (GenServer — TCP connection manager)
│ raw APRS frames parsed via aprs.parse/1
PacketProducer (GenStage — water-mark backpressure)
│ buffers incoming packets, signals backpressure upstream
PacketConsumer pool (GenStage consumers)
│ sanitize → normalize → insert into Postgres
│ broadcast to PubSub
├──► Postgres packets table (daily-partitioned)
└──► Broadcasting
├── SpatialPubSub (viewport-filtered, grid-indexed)
├── StreamingPacketsPubSub (ETS-based, bounds-filtered)
└── Phoenix.PubSub (per-callsign topics)
LiveView clients (MapLive, PacketsLive, InfoLive)
Mobile WebSocket clients (MobileChannel)
```
## Supervision Tree
```
Aprsme.Application
├── Aprsme.Repo
├── Phoenix.PubSub
├── Aprsme.Cache / DeviceCache / WeatherCache / RegexCache
├── Aprsme.RateLimiter
├── Aprsme.PacketPipelineSupervisor
│ ├── PacketProducer
│ └── PacketConsumerPool
│ └── PacketConsumer (×N)
├── Aprsme.BroadcastTaskSupervisor
├── Aprsme.Is.IsSupervisor
│ └── Aprsme.Is
├── Aprsme.SpatialPubSub
├── Aprsme.StreamingPacketsPubSub
├── Aprsme.CleanupScheduler
├── Aprsme.PartitionManager
├── Aprsme.PostgresNotifier
├── Aprsme.ConnectionMonitor
├── Aprsme.SignalHandler
├── Aprsme.ShutdownHandler
├── Aprsme.PromEx
├── Cluster modules (optional, via libcluster)
└── AprsmeWeb.Endpoint (Bandit)
```
## Key Design Decisions
### Database Partitioning
The `packets` table is **daily-partitioned** via `PartitionManager`. High ingestion volume makes partitioning essential for query performance and retention cleanup.
### Spatial Filtering
Rather than broadcasting every packet to every client, `SpatialPubSub` maintains a 1° grid spatial index. Clients register their map viewport bounds; only packets intersecting those bounds are delivered.
### Backpressure
`PacketProducer` uses water-mark backpressure (>80% buffer → pause TCP; <30% resume) to prevent memory exhaustion under load spikes.
### Symbol System
APRS symbols are rendered client-side from sprite sheets. `AprsSymbol` provides server-side symbol table lookups for info pages.
### Clustering
Optional multi-node deployment via `libcluster`. Uses leader election to designate the node that maintains the APRS-IS connection. Packets are relayed to other nodes via `PacketDistributor`.
### Lazy Asset Loading
The map bundle (Leaflet + plugins) and chart bundle (Chart.js) are loaded on-demand only for pages that need them, keeping initial page load small.

73
docs/contexts/accounts.md Normal file
View file

@ -0,0 +1,73 @@
# Accounts Context
## Overview
**Module:** `Aprsme.Accounts` (`lib/aprsme/accounts.ex`)
Handles user registration, authentication, email confirmation, password reset, and session management.
## Schemas
### User (`lib/aprsme/accounts/user.ex`)
| Field | Type | Notes |
|---|---|---|
| `id` | binary_id | Primary key |
| `email` | citext | Case-insensitive, unique |
| `callsign` | string | Optional amateur radio callsign |
| `hashed_password` | string | Bcrypt-hashed |
| `confirmed_at` | utc_datetime | Email confirmation timestamp |
| `password` | virtual | Transient, used for changesets only |
### UserToken (`lib/aprsme/accounts/user_token.ex`)
| Field | Type | Notes |
|---|---|---|
| `id` | binary_id | Primary key |
| `token` | binary | Bcrypt-hashed token |
| `context` | string | `"session"`, `"confirm"`, `"reset_password"`, `"change_email"` |
| `sent_to` | string | Email or identifier |
| `user_id` | binary_id | FK → `users.id` |
## Public API
### Registration
- `register_user/1` — Create user from registration params
- `change_user_registration/2` — Changeset for registration form
### Authentication
- `get_user_by_email_and_password/2` — Verify credentials
- `generate_user_session_token/1` — Create session token
- `get_user_by_session_token/1` — Lookup user from token
- `delete_user_session_token/1` — Logout
### Email Confirmation
- `deliver_user_confirmation_instructions/2` — Send confirmation email
- `confirm_user/1` — Mark user as confirmed
### Password Reset
- `deliver_user_reset_password_instructions/2` — Send reset email
- `get_user_by_reset_password_token/1` — Validate reset token
- `reset_user_password/2` — Update password
### Account Settings
- `change_user_email/2`, `update_user_email/2` — Email change flow
- `change_user_callsign/2`, `update_user_callsign/3` — Callsign management
- `change_user_password/2`, `update_user_password/3` — Password change
## Email Delivery
Uses `Aprsme.Accounts.UserNotifier` which delegates to `Aprsme.Mailer` (Swoosh with custom Resend adapter).
## Web Integration
- `AprsmeWeb.UserAuth` module provides plug-based and LiveView `on_mount` hooks
- Session stored in signed+encrypted cookie (`_aprs_key`)
- "Remember me" cookie (60-day expiry, signed)
- Token-based session with fixation prevention (renew on login/logout)
## Notes
- No FK relationship between `users``packets`
- Authentication is optional; most pages are publicly accessible without login
- Authenticated pages: `/users/settings`, `/dashboard` (LiveDashboard), `/errors` (ErrorTracker)

51
docs/contexts/devices.md Normal file
View file

@ -0,0 +1,51 @@
# Devices Context
## Overview
Identifies APRS equipment from packet data, maintaining a local registry of known devices.
## Schema: `Aprsme.Devices` (`lib/aprsme/devices.ex`)
Stores known APRS device information in the `devices` table:
| Field | Type | Notes |
|---|---|---|
| `identifier` | string | Unique device identifier |
| `class` | string | Device class (e.g., "tracker", "handheld") |
| `model` | string | Model name |
| `vendor` | string | Manufacturer name |
| `os` | string | Operating system |
| `contact` | string | Vendor/author contact |
| `features` | {:array, :string} | Feature tags |
No FK constraint: `device_identifier` on the `packets` table is a free-text string, not a DB-level reference.
## DeviceIdentification (`lib/aprsme/device_identification.ex`)
### Static Identification
Matches known MIC-E symbol patterns (regex-based) to identify ~20 common device types from packet data.
### Remote Registry
Fetches device database from `aprs-deviceid.aprsfoundation.org`:
- `maybe_refresh_devices/0` — Weekly refresh cycle
- `fetch_and_upsert_devices/0` — HTTP fetch wrapped in `CircuitBreaker`, upserts results into `devices` table
- `lookup_device_by_identifier/1` — Wildcard-matching lookup, delegates to `DeviceCache`
### Caching
- `DeviceCache` GenServer caches device data, refreshed daily
- Backed by `Aprsme.Cache` abstraction
## DeviceParser (`lib/aprsme/device_parser.ex`)
Extracts device identifiers from incoming packets by probing:
1. `data_extended.device_identifier` field
2. `destination` field
3. `data_extended.symbol_table_id` + `symbol_code` combination
Called during packet ingestion pipeline in `PacketConsumer`.
## Resilience
- `CircuitBreaker` wraps remote HTTP fetch, preventing cascading failures
- Cache ensures device lookups work even when remote source is unavailable
- Static patterns provide fallback identification without network calls

100
docs/contexts/packets.md Normal file
View file

@ -0,0 +1,100 @@
# Packets Context
## Overview
**Module:** `Aprsme.Packets` (`lib/aprsme/packets.ex`)
**Behaviour:** `Aprsme.PacketsBehaviour`
Core domain for APRS packet storage, retrieval, querying, and management.
## Schema: `Aprsme.Packet` (`lib/aprsme/packet.ex`)
The `packets` table is **daily-partitioned** on `received_at`. Composite primary key: `{id, received_at}`.
### Key Field Groups
| Group | Fields |
|---|---|
| **Identity** | `id`, `sender` (callsign), `base_callsign`, `ssid`, `destination`, `path` |
| **Position** | `lat` (decimal), `lon` (decimal), `location` (PostGIS geometry), `has_position` (boolean), `dao` (map) |
| **Symbol** | `symbol_code`, `symbol_table_id` |
| **Weather** | `temperature`, `humidity`, `wind_speed`, `wind_direction`, `wind_gust`, `pressure`, `rain_1h`, `rain_24h`, `rain_since_midnight`, `snow`, `has_weather` |
| **Equipment** | `manufacturer`, `equipment_type`, `course`, `speed`, `altitude` |
| **Messaging** | `addressee`, `message_text`, `message_number` |
| **Other** | `data_type`, `region`, `comment`, `raw_packet`, `device_identifier`, `data` (JSONB) |
### Changeset Pipeline
The `changeset/2` function auto-computes:
- `has_position` and `has_weather` booleans
- `location` PostGIS point from lat/lon
- Symbol normalization
- Course/wind_direction normalization
- Display-only fields swept into `data` JSONB column
## Schema: `Aprsme.BadPacket` (`lib/aprsme/bad_packet.ex`)
Failed packets stored in `badpackets` table:
- `raw_packet` — original APRS frame
- `error_message`, `error_type` — parsing/storage error details
- `attempted_at` — timestamp
## Public API
### Storage
- `store_packet/1` — Sanitize → build attrs → insert; errors → `badpackets`
- `store_bad_packet/2` — Record parse/insert failures
### Querying
- `get_recent_packets/1` — Cursor-paginated, latest first
- `get_recent_packets_for_map/1` — Lightweight map-optimized query (~22 columns)
- `get_latest_packet_for_callsign/1` — Single callsign latest
- `get_latest_positions_for_callsigns/1` — Batch latest positions
- `get_nearby_stations/3,4` — KNN spatial query via PostGIS `<->` operator
- `get_weather_packets/4` — Time-bound weather data
- `get_other_ssids/1` — Find SSID variants of a base callsign
- `get_total_packet_count/0` — Via PostgreSQL function
### Replay
- `get_packets_for_replay/1` — Historical packets filtered by callsign/bounds/region/time
- `stream_packets_for_replay/1` — Lazy stream with timing preservation
- `get_historical_packet_count/1` — Area packet density
### Weather
- `get_latest_weather_packet/1` — Latest weather for station
- `has_weather_packets?/1`, `weather_callsigns/1` — Weather presence queries
### Maintenance
- `clean_old_packets/0`, `clean_packets_older_than/1` — Retention cleanup
- `get_oldest_packet_timestamp/0` — Oldest data timestamp
## Submodules
### QueryBuilder (`lib/aprsme/packets/query_builder.ex`)
Composable Ecto query helpers: `with_position/1`, `for_callsign/2`, `within_bounds/2`, `weather_only/1`, `chronological/1`, `recent_first/1`, `select_map_fields/1`, etc.
### PreparedQueries (`lib/aprsme/packets/prepared_queries.ex`)
High-performance prepared statements for hot paths: KNN queries, weather queries, bounds-filtered queries, callsign lookups.
### Clustering (`lib/aprsme/packets/clustering.ex`)
Grid-based heatmap clustering for low-zoom map views (zoom ≤ 8). Reduces thousands of points to manageable grid cells.
## Supporting Utilities
| Module | Purpose |
|---|---|
| `Aprsme.PacketSanitizer` | Prevents DB field overflow |
| `Aprsme.PacketFieldWhitelist` | Filters fields for `insert_all` |
| `Aprsme.Encoding` / `EncodingUtils` | String/data normalization |
| `Aprsme.Convert` | Unit conversions |
| `Aprsme.Callsign` | Callsign parsing, normalization, SSID extraction |
| `Aprsme.Maidenhead` | Maidenhead grid locator conversion |
| `Aprsme.GeoUtils` | Geographic calculations |
| `Aprsme.WeatherCache` | ETS cache for weather callsign presence (TTL 5 min) |
## Database
- `packets` table: daily-partitioned, managed by `Aprsme.PartitionManager`
- `badpackets` table: standard table for error logging
- PostGIS extensions required for spatial queries
- `get_packet_count()` PostgreSQL function for efficient counting

129
docs/development/setup.md Normal file
View file

@ -0,0 +1,129 @@
# Development Setup
## Prerequisites
- Elixir 1.17+
- Erlang/OTP 26+
- PostgreSQL 16+ with PostGIS extension
- Node.js 20+ (for asset compilation)
## Quick Start
```bash
# Clone and cd
git clone <repo-url>
cd aprs.me
# Install dependencies and set up database
mix setup
# Start dev server
mix phx.server
# or with IEx:
iex -S mix phx.server
```
The application will be available at `http://localhost:4000`.
## Nix Development Environment (Optional)
A reproducible development environment is available via Nix:
```bash
nix develop
# or with direnv:
direnv allow
```
This provides all required tooling (Elixir, Erlang, PostgreSQL, Node.js) without manual installation.
## Database Setup
The `mix setup` command:
1. Installs Hex and Rebar dependencies
2. Fetches Elixir dependencies
3. Creates the development database
4. Runs migrations
5. Seeds device data
### Manual Database Setup
```bash
mix ecto.create
mix ecto.migrate
mix run priv/repo/seeds.exs
```
**Important:** PostGIS extension must be enabled on the database. The migration pipeline handles this, but ensure your PostgreSQL installation includes PostGIS.
## Running Tests
```bash
# Full test suite
mix test
# Watch mode (auto-rerun on changes)
mix test.watch
# Coverage report
mix coveralls.html
# View at cover/excoveralls.html
```
## Code Quality
```bash
# Format code
mix format
# Static analysis
mix credo --strict
# Type checking
mix dialyzer
# Security scan
mix sobelow
```
## Asset Compilation
Frontend assets use ESBuild + Tailwind CSS v4:
```bash
# Development (watch mode)
mix assets.build
# Production
MIX_ENV=prod mix assets.deploy
```
JavaScript entry points are in `assets/js/`:
- `app.ts` — Core application (always loaded)
- `map.ts` — Map functionality (lazy-loaded)
- `map_helpers.ts` — Leaflet utilities
- `map_fixes.ts` — Leaflet bug workarounds
CSS: Tailwind v4 in `assets/css/app.css` with custom dark mode and Leaflet overrides.
## Useful Commands
| Command | Purpose |
|---|---|
| `mix phx.server` | Start dev server |
| `iex -S mix phx.server` | Start with interactive shell |
| `mix test` | Run test suite |
| `mix test.watch` | Auto-rerun tests on changes |
| `mix format` | Format Elixir code |
| `mix credo --strict` | Lint check |
| `mix dialyzer` | Type checking |
| `mix sobelow` | Security scan |
| `mix coveralls.html` | Test coverage report |
| `mix phx.routes` | List all routes |
| `mix ecto.migrate` | Run pending migrations |
| `mix ecto.rollback` | Rollback last migration |
| `mix parse_file <path>` | Parse raw APRS log file |
## Project Structure
See [AGENTS.md](../../AGENTS.md) for coding conventions, and [Architecture Overview](../architecture/overview.md) for system design.

View file

@ -0,0 +1,73 @@
# Deployment
## Platforms
### Kubernetes (ArgoCD)
Primary production deployment target. Manifests in `k8s/`.
<!-- TODO: Document cluster topology, resource requirements, scaling strategy -->
### Fly.io
Alternative deployment option. Configuration in `fly.toml`.
<!-- TODO: Document Fly.io deployment steps -->
### Docker
Multi-stage Docker build (`Dockerfile`).
- Build stage: Elixir compilation + asset bundling
- Release stage: Minimal runtime with Erlang/Elixir release
## Release Management
### Building
```bash
MIX_ENV=prod mix assets.deploy
mix release
```
### Database Migrations
`Aprsme.Release` module handles migrations during release startup.
<!-- TODO: Document migration strategy (rolling updates, advisory locks) -->
## Environment Configuration
Configuration files in `config/`:
- `config.exs` — Base configuration
- `dev.exs` — Development overrides
- `prod.exs` — Production defaults
- `runtime.exs` — Runtime configuration (from env vars)
### Key Environment Variables
<!-- TODO: Document required env vars (DATABASE_URL, SECRET_KEY_BASE, APRS_IS credentials, etc.) -->
## Development Environment
### Nix Shell
Nix-based reproducible dev environment via `flake.nix` / `shell.nix`. Provides Elixir, Erlang, PostgreSQL, and all tooling.
### Dev Containers
`.devcontainer/` configuration for VS Code / GitHub Codespaces.
## Clustering
Optional multi-node deployment via `libcluster`:
- Leader election determines which node maintains APRS-IS connection
- Packet distribution relayed across nodes
- Configuration in cluster modules under `lib/aprsme/cluster/`
## Health Checks
- `/health` — Kubernetes liveness (basic) and readiness (DB + PubSub, shutdown-aware)
- `/ready` — App readiness check
- `/status.json` — JSON status endpoint
Handled by `HealthCheck` plug before router.

View file

@ -0,0 +1,65 @@
# Observability
## Metrics
### PromEx (`Aprsme.PromEx`)
Prometheus-compatible metrics exposed at `/metrics` (PromEx plugin).
Custom plugin: `Aprsme.PromEx.Plugins.Aprsme`
<!-- TODO: Document key metrics (packet rates, DB query times, broadcast latency) -->
### Telemetry
`telemetry_metrics` + `telemetry_poller` for application-level metrics:
- `Aprsme.Telemetry.DatabaseMetrics` — DB query performance
- `Aprsme.ApiMetrics` — API usage tracking
## Error Tracking
### ErrorTracker
Self-hosted error tracking dashboard at `/errors` (authenticated).
Configuration: `error_tracker` hex package.
### Error Handling
- `Aprsme.ErrorHandler` — Application error handling
- `Aprsme.ErrorNotifier` — Telemetry-based error notification
## Logging
### Log Sanitization
`Aprsme.LogSanitizer` — Strips PII from log output.
### Log Filtering
`AprsmeWeb.Plugs.LogFilter` — Filters noisy paths (`/health`, `/ready`, `/`) from access logs.
## Monitoring
### Connection Monitor
`Aprsme.ConnectionMonitor` — Monitors APRS-IS connection health.
### Status Page
`/status` (StatusLive.Index) — Real-time system status dashboard:
- APRS-IS connection state
- Uptime
- Health score
- Cluster status (when clustered)
### Analytics
`Aprsme.PlausibleAnalytics` — Plausible analytics integration for page views.
## Signal Handling
- `Aprsme.SignalHandler` — OS signal handling (SIGTERM, SIGINT)
- `Aprsme.ShutdownHandler` — Graceful shutdown coordination
## Deployment Notifications
`Aprsme.DeploymentNotifier` — Notifies external systems of deploy events.

56
docs/web/api-rest.md Normal file
View file

@ -0,0 +1,56 @@
# REST API v1
## Overview
Public REST API for programmatic access to APRS packet data.
**Base URL:** `/api/v1`
**Content-Type:** `application/json`
**Auth Pipeline:** `:api` — JSON accepts, rate-limited (100 req/min), API CSRF protection
## Endpoints
### `GET /api/v1/callsign/:callsign`
Returns the latest packet for a given amateur radio callsign.
**Controller:** `AprsmeWeb.Api.V1.CallsignController`
**View:** `AprsmeWeb.Api.V1.CallsignJSON`
**Parameters:**
| Param | Type | Required | Description |
|---|---|---|---|
| `callsign` | string | yes | Valid amateur radio callsign (path param) |
<!-- TODO: Document response schema, example request/response -->
### `GET /api/v1/weather/nearby`
Returns nearby weather station data.
**Controller:** `AprsmeWeb.Api.V1.WeatherController`
**View:** `AprsmeWeb.Api.V1.WeatherJSON`
<!-- TODO: Document query parameters (lat, lon, radius, limit), response schema, example -->
## Error Handling
API errors are rendered via `ErrorJSON` and `ChangesetJSON` view modules. The `FallbackController` handles generic error cases.
<!-- TODO: Document error response format, status codes -->
## Rate Limiting
API endpoints are rate-limited to 100 requests per minute per client via `RateLimiter` plug (ETS-based).
## CSRF Protection
API requests must include either:
- `X-Requested-With: XMLHttpRequest` header, or
- Valid CSRF token
Enforced by `ApiCSRF` plug.
## Interactive Docs
A live API documentation page with interactive testing form is available at `/api` (`ApiDocsLive`).

28
docs/web/channels.md Normal file
View file

@ -0,0 +1,28 @@
# Mobile WebSocket Channel
## Overview
Real-time packet streaming for iOS/Android mobile applications.
**Socket:** `MobileUserSocket` (`/mobile`, WebSocket only)
**Channel:** `MobileChannel`
## Connection
Mobile clients connect via WebSocket to `/mobile`:
```
ws://host/mobile/websocket?vsn=2.0.0
```
## Protocol
See [mobile-api.md](../mobile-api.md) for the complete message format and protocol specification.
## Architecture
The mobile channel receives real-time packet broadcasts from the same PubSub sources as the web LiveView clients:
- `StreamingPacketsPubSub` — bounds-filtered
- `SpatialPubSub` — viewport-filtered
- Per-callsign Phoenix PubSub topics
Packets are formatted into a mobile-optimized JSON structure and pushed to connected clients.

81
docs/web/liveview-map.md Normal file
View file

@ -0,0 +1,81 @@
# LiveView Map
## Overview
**Module:** `AprsmeWeb.MapLive.Index` (~2050 lines, the largest LiveView)
**Routes:** `/` (main map), `/:callsign` (tracked station)
The map is the primary user interface — a real-time Leaflet-based map displaying live APRS station positions, trails, weather data, and station information.
## MapLive Architecture
`MapLive.Index` is supported by 12 submodules under `lib/aprsme_web/live/map_live/`:
```
MapLive.Index
├── Components — Reusable UI (map container, slideover panel, packet list)
├── PopupComponent — Marker popup rendering (callsign, weather, timestamp)
├── DataBuilder — Constructs map data payloads for JS hooks
├── DisplayManager — Manages what's shown on map (stations, trails, clusters)
├── HistoricalLoader — Loads historical packet data for selected station
├── Navigation — Determines map center/zoom from URL params
├── PacketBatcher — Batches incoming real-time packets for rendering
├── PacketProcessor — Processes raw packets into display-ready data
├── RfPath — RF path visualization (digipeater hops)
├── UrlParams — URL parameter sync with map state
└── (Plus shared modules in live/shared/)
```
## Real-Time Updates
MapLive subscribes to:
- `StreamingPacketsPubSub` — viewport-filtered new packets
- `SpatialPubSub` — grid-indexed spatial delivery
- Phoenix PubSub topics for callsign-specific updates
Updates are batched via `PacketBatcher` to reduce DOM operations, then pushed to the Leaflet client via `push_event` (e.g., `"update_markers"`, `"update_clusters"`).
## Client-Side
The `APRSMap` JavaScript hook (`assets/js/map.ts`) handles:
- Leaflet map initialization with multiple tile layers
- Marker management (add, update, remove)
- Marker clustering at lower zoom levels
- Trail line rendering (`trail_manager.ts`)
- Viewport change tracking (pushed back to server for subscription bounds)
- URL parameter sync (center, zoom)
## Lazy Loading
The map bundle (Leaflet + plugins, ~300KB) is loaded only when a map page is visited, via `VendorLoader.loadMap()` in `assets/js/app.ts`. Non-map pages never download it.
## Key Features
### Station Tracking
When visiting `/:callsign`, the map centers on that station and subscribes to its specific PubSub topic. A slideover panel shows station details.
### Trail Lines
Historical path of a station rendered as a polyline on the map. See [trail-line-visualization.md](../features/trail-line-visualization.md).
### RF Path
Digipeater hop visualization showing the path an APRS packet took through the network.
### Heatmap Clustering
At low zoom levels (≤8), individual markers are replaced by grid-based heatmap clusters via `Packets.Clustering` for performance.
### Weather Overlay
Weather station markers with temperature/wind indicators. Clicking opens a popup with current conditions, linking to the full weather page.
## Slideover Panel
When a station is selected, a slideover panel displays:
- Latest position and timestamp
- Course, speed, altitude
- Device/manufacturer info
- Link to full info page (`/info/:callsign`)
- Link to packet history (`/packets/:callsign`)
- Weather data (if weather station)
## URL State
Map state (center, zoom, selected callsign) is synced to URL query parameters via `UrlParams`, enabling shareable map views and browser back/forward navigation.

View file

@ -0,0 +1,88 @@
# LiveView Pages
Non-map LiveView pages in the application.
## Packet Streams
### PacketsLive.Index (`/packets`)
- Real-time stream of all incoming packets (max 100)
- Subscribes to `"postgres:aprsme_packets"` Phoenix PubSub topic
- Uses `stream/4` for efficient DOM updates
### PacketsLive.CallsignView (`/packets/:callsign`)
- Filtered packet stream for a single callsign
- Subscribes to `"packets:#{callsign}"` topic
- Includes device information parsed per-packet
## Station Info
### InfoLive.Show (`/info/:callsign`)
- Comprehensive station information page
- Latest packet details, device identification
- Nearby stations via spatial query (neighbors)
- APRS path decoding — "heard by" and "stations heard" analysis
- Digipeater relationships
- APRS symbol rendering
- Embedded single-station map via `InfoMapComponent` (JS hook)
- Subscribes to `"packets:#{callsign}"` for live updates
## Weather
### WeatherLive.CallsignView (`/weather/:callsign`)
- Weather station data visualization with Chart.js charts
- Temperature, humidity, wind, pressure, rainfall trends
- Locale-aware unit conversions (metric/imperial)
- Subscribes to `"weather:#{callsign}"` for live updates
- Pushes `"update_weather_charts"` events to refresh Chart.js
## Status
### StatusLive.Index (`/status`)
- System status dashboard
- APRS-IS connection state, uptime, health score
- Cluster node information (when clustered)
- Polls every 5 seconds via `Process.send_after`
- Inline `render/1` (no template file)
- Subscribes to `"aprs_status"` topic
## Bad Packets
### BadPacketsLive.Index (`/badpackets`)
- Malformed/rejected packet viewer
- Shows raw packet data + error type/message
- Debounced refresh (2s delay)
- Subscribes to `"postgres:aprsme_events"`
## Static Pages
### AboutLive (`/about`)
- Simple about page, title assign only
### ApiDocsLive (`/api`)
- API documentation with interactive testing form
- Inline render (~970 lines)
- Self-documenting with example requests/responses
## Authentication Pages
All auth LiveViews use inline `render/1`:
| LiveView | Route | Auth Pipeline |
|---|---|---|
| `UserLoginLive` | `/users/log_in` | `redirect_if_user_is_authenticated` |
| `UserRegistrationLive` | `/users/register` | `redirect_if_user_is_authenticated` |
| `UserForgotPasswordLive` | `/users/reset_password` | `redirect_if_user_is_authenticated` |
| `UserResetPasswordLive` | `/users/reset_password/:token` | `redirect_if_user_is_authenticated` |
| `UserConfirmationLive` | `/users/confirm/:token` | `:current_user` |
| `UserConfirmationInstructionsLive` | `/users/confirm` | `:current_user` |
| `UserSettingsLive` | `/users/settings` | `require_authenticated_user` |
## Shared LiveView Modules
Located in `lib/aprsme_web/live/shared/`:
- `BoundsUtils` — Map boundary calculations
- `PacketHandler` — Shared packet enrichment
- `ParamUtils` — URL parameter parsing
- `PacketUtils` — Shared packet querying
- `CoordinateUtils` — Coordinate math

124
flake.lock generated Normal file
View file

@ -0,0 +1,124 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1769996383,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784120854,
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1769939035,
"narHash": "sha256-Fok2AmefgVA0+eprw2NDwqKkPGEI5wvR+twiZagBvrg=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "a8ca480175326551d6c4121498316261cbb5b260",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": "pre-commit-hooks",
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

73
flake.nix Normal file
View file

@ -0,0 +1,73 @@
{
description = "aprs.me - Phoenix LiveView APRS packet mapping platform";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
systems.url = "github:nix-systems/default";
pre-commit-hooks = {
url = "github:cachix/pre-commit-hooks.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
inputs@{
self,
nixpkgs,
flake-parts,
systems,
pre-commit-hooks,
}:
flake-parts.lib.mkFlake { inherit inputs; } {
# nixpkgs 26.11 dropped x86_64-darwin support
systems = builtins.filter (s: s != "x86_64-darwin") (import systems);
perSystem =
{
config,
self',
inputs',
pkgs,
system,
...
}:
{
_module.args.pkgs = import nixpkgs {
inherit system;
overlays = [
# Pin specific Elixir and Erlang versions to match .tool-versions
# Elixir 1.20.2-otp-29, Erlang 29.0.1
(final: prev: {
# Use Erlang 29 — no top-level erlang_29 yet, use beam.interpreters
erlang = prev.beam.interpreters.erlang_29;
# Use Elixir 1.20 with Erlang 29 (1.20.2-otp-29)
elixir = prev.beam.packages.erlang_29.elixir_1_20;
# Ensure beam packages use our pinned versions
beamPackages = prev.beam.packages.erlang_29.extend (
self: super: {
elixir = super.elixir_1_20;
}
);
})
];
};
# Development shell with PostgreSQL (PostGIS), LSPs, formatters
devShells.default = pkgs.callPackage ./nix/shell.nix {
pre-commit-hooks = inputs.pre-commit-hooks.lib.${system};
};
# Nix formatter for flake files
formatter = pkgs.nixfmt;
};
};
}

View file

@ -7,8 +7,6 @@ defmodule Aprsme.Application do
require Logger
# Configure Oban for background jobs
@impl true
def start(_type, _args) do
# Initialize deployment timestamp
@ -53,8 +51,6 @@ defmodule Aprsme.Application do
# Start cleanup scheduler for periodic packet cleanup
Aprsme.CleanupScheduler,
Aprsme.PostgresNotifier,
# Start deployment notifier
Aprsme.DeploymentNotifier,
# Start the packet processing pipeline
Aprsme.PacketPipelineSupervisor
]

View file

@ -1,57 +1,11 @@
defmodule Aprsme.DeploymentNotifier do
@moduledoc """
Monitors for deployment changes and notifies connected clients.
In k8s, this detects when the DEPLOYED_AT environment variable changes.
Notifies connected clients about deployment changes.
Called from the release module when a deployment is detected.
"""
use GenServer
require Logger
@check_interval 30_000
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(_opts) do
# Schedule first check
Process.send_after(self(), :check_deployment, @check_interval)
# Get initial deployment timestamp
deployed_at = Aprsme.Release.deployed_at()
{:ok, %{deployed_at: deployed_at}}
end
def handle_info(:check_deployment, state) do
# Schedule next check
Process.send_after(self(), :check_deployment, @check_interval)
# Get current deployment timestamp
current_deployed_at = Aprsme.Release.deployed_at()
# Check if deployment timestamp changed (shouldn't happen in same process, but useful for monitoring)
if current_deployed_at == state.deployed_at do
{:noreply, state}
# Broadcast the new deployment
else
Logger.info("Deployment timestamp changed from #{state.deployed_at} to #{current_deployed_at}")
_ =
Phoenix.PubSub.broadcast(
Aprsme.PubSub,
"deployment_events",
{:new_deployment, %{deployed_at: current_deployed_at}}
)
{:noreply, %{state | deployed_at: current_deployed_at}}
end
end
@doc """
Notify about a new deployment immediately.
This can be called from the release module when deployment is detected.
"""
def notify_deployment(deployed_at) do
Phoenix.PubSub.broadcast(

View file

@ -1,6 +1,6 @@
defmodule Aprsme.Packet do
@moduledoc false
use Aprsme.Schema
use Ecto.Schema
import Ecto.Changeset
@ -8,6 +8,9 @@ defmodule Aprsme.Packet do
alias Aprsme.DataExtended
alias AprsmeWeb.Live.Shared.CoordinateUtils
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "packets" do
field(:base_callsign, :string)
field(:data_type, :string)

View file

@ -1,11 +0,0 @@
defmodule Aprsme.Schema do
@moduledoc false
defmacro __using__(_) do
quote do
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
end
end
end

View file

@ -5,6 +5,8 @@ defmodule Aprsme.SpatialPubSub do
"""
use GenServer
require Logger
# Grid size in degrees for spatial indexing
@grid_size 1.0
# Maximum number of concurrent clients
@ -252,7 +254,6 @@ 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

View file

@ -14,15 +14,16 @@ defmodule Aprsme.WeatherCache do
The table is typically created in application.ex alongside other ETS tables.
"""
def setup do
if :ets.whereis(@cache_name) == :undefined do
:ets.new(@cache_name, [
:named_table,
:public,
:set,
read_concurrency: true,
write_concurrency: true
])
end
_table =
if :ets.whereis(@cache_name) == :undefined do
:ets.new(@cache_name, [
:named_table,
:public,
:set,
read_concurrency: true,
write_concurrency: true
])
end
:ok
rescue

View file

@ -36,14 +36,14 @@
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<!-- Minimal vendor CSS -->
<!-- Minimal vendor CSS -->
<link phx-track-static rel="stylesheet" href={~p"/assets/vendor/css/minimal-bundle.css"} />
<!-- Core utilities - always loaded -->
<!-- Core utilities - always loaded -->
<script phx-track-static src={~p"/assets/vendor/js/core-bundle.js"}>
</script>
<!-- Conditional loading based on page type -->
<!-- Conditional loading based on page type -->
<script>
window.VendorLoader = {
mapBundleUrl: '/assets/vendor/js/map-bundle.js',
@ -73,11 +73,11 @@
};
</script>
<!-- App scripts -->
<!-- App scripts -->
<script phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
<!-- Privacy-friendly analytics by Plausible -->
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://a.w5isp.com/js/pa-7BkaTcdFcxr5y-7yhqhlJ.js">
</script>
<script>

View file

@ -5,13 +5,25 @@ defmodule AprsmeWeb.Endpoint do
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
@default_session_options [
store: :cookie,
key: "_aprs_key",
signing_salt: "0toQ/Ejk",
encryption_salt: "local-dev-only-encryption-salt",
secure: false,
same_site: "Lax"
]
# Computed at compile-time by merging defaults with any endpoint config
@session_options Keyword.merge(
@default_session_options,
:aprsme
|> Application.compile_env(AprsmeWeb.Endpoint, [])
|> Keyword.get(:session_options, [])
)
def session_options, do: @session_options
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options], timeout: 60_000],
longpoll: [connect_info: [session: @session_options]]

View file

@ -364,7 +364,7 @@ defmodule AprsmeWeb.ApiDocsLive do
</p>
</div>
<!-- API Overview -->
<!-- API Overview -->
<div class="bg-white shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10 mb-8">
<div class="px-6 py-8">
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white">Overview</h2>
@ -416,7 +416,7 @@ defmodule AprsmeWeb.ApiDocsLive do
</div>
</div>
<!-- API Endpoints -->
<!-- API Endpoints -->
<div class="space-y-8">
<!-- Callsign Endpoint -->
<div class="bg-white shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
@ -500,7 +500,7 @@ defmodule AprsmeWeb.ApiDocsLive do
</div>
</div>
<!-- Weather Nearby Endpoint -->
<!-- Weather Nearby Endpoint -->
<div class="bg-white shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<div class="px-6 py-8">
<div class="flex items-center justify-between mb-4">
@ -628,7 +628,7 @@ defmodule AprsmeWeb.ApiDocsLive do
</div>
</div>
<!-- Response Fields Documentation -->
<!-- Response Fields Documentation -->
<div class="bg-white shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<div class="px-6 py-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Response Fields</h2>
@ -727,7 +727,7 @@ defmodule AprsmeWeb.ApiDocsLive do
</div>
</div>
<!-- HTTP Status Codes -->
<!-- HTTP Status Codes -->
<div class="bg-white shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<div class="px-6 py-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">HTTP Status Codes</h2>
@ -790,7 +790,7 @@ defmodule AprsmeWeb.ApiDocsLive do
</div>
</div>
<!-- Future Endpoints -->
<!-- Future Endpoints -->
<div class="bg-white shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<div class="px-6 py-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Planned Endpoints</h2>
@ -844,7 +844,7 @@ defmodule AprsmeWeb.ApiDocsLive do
</div>
</div>
<!-- Interactive API Testing -->
<!-- Interactive API Testing -->
<div class="bg-white shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<div class="px-6 py-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Test the API</h2>
@ -898,7 +898,7 @@ defmodule AprsmeWeb.ApiDocsLive do
</form>
</div>
<!-- Error Display -->
<!-- Error Display -->
<%= if @error do %>
<div class="rounded-md bg-red-50 p-4 dark:bg-red-500/10 mt-4 max-w-md">
<div class="flex">
@ -921,7 +921,7 @@ defmodule AprsmeWeb.ApiDocsLive do
</div>
<% end %>
<!-- Results Display -->
<!-- Results Display -->
<%= if @api_result do %>
<div class="mt-4">
<h4 class="text-lg font-medium mb-2">API Response</h4>
@ -947,7 +947,7 @@ defmodule AprsmeWeb.ApiDocsLive do
</div>
</div>
<!-- Contact and Support -->
<!-- Contact and Support -->
<div class="bg-white shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<div class="px-6 py-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Support</h2>

View file

@ -25,7 +25,7 @@ defmodule AprsmeWeb.SymbolRenderer do
def symbol(assigns) do
# Get the sprite file and position for this symbol
sprite_info = get_sprite_info(assigns.symbol_table, assigns.symbol_code)
sprite_info = AprsmeWeb.AprsSymbol.get_sprite_info(assigns.symbol_table, assigns.symbol_code)
assigns =
assign(assigns,
@ -82,20 +82,4 @@ defmodule AprsmeWeb.SymbolRenderer do
</div>
"""
end
@doc """
Gets sprite information for a given symbol table and code.
Returns a map with sprite_file, background_position, and background_size.
"""
def get_sprite_info(symbol_table, symbol_code) do
AprsmeWeb.AprsSymbol.get_sprite_info(symbol_table, symbol_code)
end
@doc """
Renders an APRS symbol for use in Leaflet markers.
Returns HTML string that can be used as marker content.
"""
def render_marker_symbol(symbol_table, symbol_code, callsign \\ nil, size \\ 32) do
AprsmeWeb.AprsSymbol.render_marker_html(symbol_table, symbol_code, callsign, size)
end
end

View file

@ -81,9 +81,6 @@ defmodule AprsmeWeb.InfoLive.Show do
has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign)
other_ssids = Packets.get_other_ssids(normalized_callsign)
heard_by_stations = get_heard_by_stations(normalized_callsign, locale)
stations_heard_by = get_stations_heard_by(normalized_callsign, locale)
socket =
socket
|> assign(:callsign, normalized_callsign)
@ -92,8 +89,8 @@ defmodule AprsmeWeb.InfoLive.Show do
|> assign(:page_title, "APRS station #{normalized_callsign}")
|> assign(:has_weather_packets, has_weather_packets)
|> assign(:other_ssids, other_ssids)
|> assign(:heard_by_stations, heard_by_stations)
|> assign(:stations_heard_by, stations_heard_by)
send(self(), :load_aggregations)
{:ok, socket}
end
@ -105,6 +102,15 @@ defmodule AprsmeWeb.InfoLive.Show do
process_packet_update(packet, socket)
end
@impl true
def handle_info(:load_aggregations, socket) do
locale = Map.get(socket.assigns, :locale, "en")
heard_by_stations = get_heard_by_stations(socket.assigns.callsign, locale)
stations_heard_by = get_stations_heard_by(socket.assigns.callsign, locale)
{:noreply, assign(socket, heard_by_stations: heard_by_stations, stations_heard_by: stations_heard_by)}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp process_packet_update(incoming_packet, socket) do
@ -326,7 +332,27 @@ defmodule AprsmeWeb.InfoLive.Show do
# Query to find stations that heard this callsign directly
# In APRS, the first station with an asterisk (*) in the path is the one that heard the packet directly
query = """
WITH parsed_paths AS (
WITH digipeater_location AS (
SELECT DISTINCT ON (sender)
sender as digipeater,
location::geography as dig_loc
FROM packets
WHERE sender IN (
SELECT DISTINCT
substring(path from '([A-Z0-9]+-?[0-9]*)\\*')
FROM packets
WHERE sender = $1
AND received_at >= $2
AND path IS NOT NULL
AND path != ''
AND path !~ '^TCPIP'
AND path !~ ',TCPIP'
AND path ~ '[A-Z0-9]+-?[0-9]*\\*'
)
AND location IS NOT NULL
ORDER BY sender, received_at DESC
),
parsed_paths AS (
SELECT
id,
sender,
@ -349,33 +375,34 @@ defmodule AprsmeWeb.InfoLive.Show do
AND path !~ '^TCPIP'
AND path !~ ',TCPIP'
),
longest_paths AS (
SELECT DISTINCT ON (pp.first_digipeater)
pp.first_digipeater,
pp.id as longest_path_packet_id
FROM parsed_paths pp
WHERE pp.first_digipeater IS NOT NULL
AND pp.lat IS NOT NULL
AND pp.lon IS NOT NULL
ORDER BY pp.first_digipeater,
ST_Distance(
pp.location::geography,
COALESCE(
(SELECT dl.dig_loc FROM digipeater_location dl WHERE dl.digipeater = pp.first_digipeater),
pp.location::geography
)
) DESC NULLS LAST
),
digipeater_stats AS (
SELECT
first_digipeater as digipeater,
MIN(received_at) as first_heard,
MAX(received_at) as last_heard,
pp.first_digipeater as digipeater,
MIN(pp.received_at) as first_heard,
MAX(pp.received_at) as last_heard,
COUNT(*) as packet_count,
-- Find the packet with maximum distance for this digipeater
(SELECT pp2.id
FROM parsed_paths pp2
WHERE pp2.first_digipeater = pp.first_digipeater
AND pp2.lat IS NOT NULL
AND pp2.lon IS NOT NULL
ORDER BY
ST_Distance(
pp2.location::geography,
(SELECT location::geography
FROM packets
WHERE sender = pp2.first_digipeater
AND location IS NOT NULL
ORDER BY received_at DESC
LIMIT 1)
) DESC NULLS LAST
LIMIT 1
) as longest_path_packet_id
MAX(lp.longest_path_packet_id) as longest_path_packet_id
FROM parsed_paths pp
WHERE first_digipeater IS NOT NULL
GROUP BY first_digipeater
LEFT JOIN longest_paths lp ON lp.first_digipeater = pp.first_digipeater
WHERE pp.first_digipeater IS NOT NULL
GROUP BY pp.first_digipeater
)
SELECT
ds.digipeater,
@ -384,20 +411,13 @@ defmodule AprsmeWeb.InfoLive.Show do
ds.packet_count,
p.received_at as longest_path_time,
CASE
WHEN p.location IS NOT NULL AND dig_loc.location IS NOT NULL THEN
ST_Distance(p.location::geography, dig_loc.location::geography) / 1000.0
WHEN p.location IS NOT NULL AND dl.dig_loc IS NOT NULL THEN
ST_Distance(p.location::geography, dl.dig_loc) / 1000.0
ELSE NULL
END as longest_distance_km
FROM digipeater_stats ds
LEFT JOIN packets p ON p.id = ds.longest_path_packet_id
LEFT JOIN LATERAL (
SELECT location
FROM packets
WHERE sender = ds.digipeater
AND location IS NOT NULL
ORDER BY received_at DESC
LIMIT 1
) dig_loc ON true
LEFT JOIN digipeater_location dl ON dl.digipeater = ds.digipeater
ORDER BY ds.last_heard DESC
LIMIT 50
"""

View file

@ -186,7 +186,7 @@
</div>
</div>
<!-- Map showing station location -->
<!-- Map showing station location -->
<%= if @packet.lat && @packet.lon do %>
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10 h-full">
<% {symbol_table, symbol_code} = AprsmeWeb.AprsSymbol.extract_from_packet(@packet) %>
@ -203,7 +203,7 @@
</div>
<% end %>
<!-- Row for Device Information and Other SSIDs -->
<!-- Row for Device Information and Other SSIDs -->
<div class="lg:col-span-2 grid grid-cols-1 gap-4 lg:grid-cols-2">
<!-- Device information -->
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10">
@ -294,7 +294,7 @@
</div>
</div>
<!-- Other SSIDs -->
<!-- Other SSIDs -->
<%= if length(@other_ssids) > 0 do %>
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10">
<div class="px-6 py-6">
@ -408,7 +408,7 @@
</div>
</div>
<!-- Neighboring stations -->
<!-- Neighboring stations -->
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10">
<div class="px-6 py-6">
<div class="flex items-center mb-3">
@ -503,7 +503,7 @@
</div>
</div>
<!-- Stations that heard this callsign on RF -->
<!-- Stations that heard this callsign on RF -->
<%= if length(@heard_by_stations) > 0 do %>
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10 mt-6">
<div class="px-6 py-6">
@ -608,7 +608,7 @@
</div>
<% end %>
<!-- Stations heard directly by this callsign -->
<!-- Stations heard directly by this callsign -->
<%= if length(@stations_heard_by) > 0 do %>
<div class="bg-white shadow-xs sm:rounded-lg ring-1 ring-gray-950/5 dark:bg-gray-800/50 dark:shadow-none dark:ring-white/10 mt-6">
<div class="px-6 py-6">

View file

@ -113,7 +113,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
# would generate anyway, saving the client from icon re-creation
symbol_html =
if is_most_recent do
AprsmeWeb.SymbolRenderer.render_marker_symbol(
AprsmeWeb.AprsSymbol.render_marker_html(
symbol_table_id,
symbol_code,
label,
@ -217,12 +217,12 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
def build_simple_popup(packet, has_weather) do
packet
|> build_simple_popup_data()
|> Map.put(:weather_link, has_weather || weather_packet?(packet))
|> Map.put(:weather_link, has_weather || SharedPacketUtils.has_weather_data?(packet))
|> render_popup()
end
defp build_simple_popup_data(packet) do
is_weather = weather_packet?(packet)
is_weather = SharedPacketUtils.has_weather_data?(packet)
if is_weather do
%{
@ -278,7 +278,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
{position_packets, weather_packets} =
Enum.split_with(packets, fn packet ->
# A packet is a position packet if it's NOT a weather packet
not weather_packet?(packet)
not SharedPacketUtils.has_weather_data?(packet)
end)
# Prefer the most recent position packet, fall back to most recent weather packet
@ -304,7 +304,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
@spec build_weather_callsign_set(list()) :: MapSet.t()
def build_weather_callsign_set(packets) do
packets
|> Enum.filter(&weather_packet?/1)
|> Enum.filter(&SharedPacketUtils.has_weather_data?/1)
|> MapSet.new(fn packet -> String.upcase(display_name(packet)) end)
end
@ -332,7 +332,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
timestamp: get_timestamp(packet),
comment: clean_comment,
safe_data_extended: convert_tuples_to_strings(data_extended),
is_weather_packet: weather_packet?(packet)
is_weather_packet: SharedPacketUtils.has_weather_data?(packet)
}
end
@ -457,7 +457,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
# Generate symbol HTML using the server-side renderer
symbol_html =
AprsmeWeb.SymbolRenderer.render_marker_symbol(
AprsmeWeb.AprsSymbol.render_marker_html(
packet_info.symbol_table_id,
packet_info.symbol_code,
packet_info.callsign,
@ -523,11 +523,6 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
SharedPacketUtils.map_label(packet)
end
@spec weather_packet?(map()) :: boolean()
defp weather_packet?(packet) do
SharedPacketUtils.weather_packet?(packet)
end
@spec has_weather_packets?(String.t()) :: boolean()
defp has_weather_packets?(callsign) when is_binary(callsign) do
case Aprsme.WeatherCache.weather_callsign?(callsign) do

View file

@ -40,7 +40,7 @@ defmodule AprsmeWeb.MapLive.Index do
# Basic setup
deployed_at = Aprsme.Release.deployed_at()
one_hour_ago = TimeUtils.one_day_ago()
one_day_ago = TimeUtils.hours_ago(24)
# Parse and determine map location
{map_center, map_zoom, should_skip_initial_url_update} = Navigation.determine_map_location(params, session)
@ -96,7 +96,7 @@ defmodule AprsmeWeb.MapLive.Index do
should_skip_initial_url_update: should_skip_initial_url_update,
tracked_callsign: tracked_callsign,
deployed_at: deployed_at,
one_hour_ago: one_hour_ago
one_day_ago: one_day_ago
})}
end
@ -170,12 +170,12 @@ defmodule AprsmeWeb.MapLive.Index do
should_skip_initial_url_update: should_skip_initial_url_update,
tracked_callsign: tracked_callsign,
deployed_at: deployed_at,
one_hour_ago: one_hour_ago
one_day_ago: one_day_ago
}) do
# Don't override trail_duration and historical_hours if they're already set
trail_duration = Map.get(socket.assigns, :trail_duration, "1")
historical_hours = Map.get(socket.assigns, :historical_hours, "1")
packet_age_threshold = Map.get(socket.assigns, :packet_age_threshold, one_hour_ago)
packet_age_threshold = Map.get(socket.assigns, :packet_age_threshold, one_day_ago)
# If tracking a specific callsign, fetch their latest packet and other SSIDs
{tracked_callsign_latest_packet, other_ssids} =
@ -251,7 +251,7 @@ defmodule AprsmeWeb.MapLive.Index do
end
@spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t()
defp assign_defaults(socket, one_hour_ago) do
defp assign_defaults(socket, one_day_ago) do
assign(socket,
packets: [],
visible_packets: %{},
@ -266,7 +266,7 @@ defmodule AprsmeWeb.MapLive.Index do
},
map_center: UrlParams.default_center(),
map_zoom: UrlParams.default_zoom(),
packet_age_threshold: one_hour_ago,
packet_age_threshold: one_day_ago,
map_ready: false,
historical_loaded: false,
bounds_update_timer: nil,
@ -1458,7 +1458,7 @@ defmodule AprsmeWeb.MapLive.Index do
</p>
</div>
<!-- Content -->
<!-- Content -->
<div class="relative flex-1 overflow-y-auto bg-white px-5 py-5 dark:bg-gray-800 dark:after:absolute dark:after:inset-y-0 dark:after:left-0 dark:after:w-px dark:after:bg-white/10">
<div class="space-y-5">
<!-- Callsign Search -->
@ -1508,7 +1508,7 @@ defmodule AprsmeWeb.MapLive.Index do
</form>
</div>
<!-- Other SSIDs -->
<!-- Other SSIDs -->
<%= if @tracked_callsign != "" and @other_ssids != [] do %>
<div class="rounded-lg bg-gray-50 p-4 ring-1 ring-gray-950/5 dark:bg-gray-900/50 dark:ring-white/10">
<label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
@ -1559,7 +1559,7 @@ defmodule AprsmeWeb.MapLive.Index do
</div>
<% end %>
<!-- Trail Duration -->
<!-- Trail Duration -->
<div class="rounded-lg bg-gray-50 p-4 ring-1 ring-gray-950/5 dark:bg-gray-900/50 dark:ring-white/10">
<label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
<svg class="h-3.5 w-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1572,7 +1572,7 @@ defmodule AprsmeWeb.MapLive.Index do
</svg>
{gettext("Trail Duration")}
</label>
<form phx-change="update_trail_duration">
<form id="trail-duration-form" phx-change="update_trail_duration">
<select
name="trail_duration"
class="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 shadow-xs outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500"
@ -1599,7 +1599,7 @@ defmodule AprsmeWeb.MapLive.Index do
</form>
</div>
<!-- Historical Data -->
<!-- Historical Data -->
<div class="rounded-lg bg-gray-50 p-4 ring-1 ring-gray-950/5 dark:bg-gray-900/50 dark:ring-white/10">
<label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1612,7 +1612,7 @@ defmodule AprsmeWeb.MapLive.Index do
</svg>
{gettext("Historical Data")}
</label>
<form phx-change="update_historical_hours">
<form id="historical-hours-form" phx-change="update_historical_hours">
<select
name="historical_hours"
class="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 shadow-xs outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500"
@ -1636,7 +1636,7 @@ defmodule AprsmeWeb.MapLive.Index do
</form>
</div>
<!-- Navigation -->
<!-- Navigation -->
<div class="border-t border-gray-200 pt-4 dark:border-white/10">
<label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1659,7 +1659,7 @@ defmodule AprsmeWeb.MapLive.Index do
</nav>
</div>
<!-- Status Footer -->
<!-- Status Footer -->
<div class="border-t border-gray-200 pt-4 dark:border-white/10">
<div class="grid grid-cols-2 gap-4">
<div>

View file

@ -11,6 +11,8 @@ defmodule AprsmeWeb.PacketsLive.CallsignView do
alias Aprsme.Repo
alias AprsmeWeb.Live.SharedPacketHandler
@flush_interval_ms 150
@impl true
def mount(%{"callsign" => callsign}, _session, socket) do
normalized = Callsign.normalize(callsign)
@ -25,9 +27,10 @@ defmodule AprsmeWeb.PacketsLive.CallsignView do
end
defp subscribe_if_connected(socket, callsign) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{callsign}")
end
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{callsign}")
end
socket
end
@ -39,6 +42,8 @@ defmodule AprsmeWeb.PacketsLive.CallsignView do
|> assign(:live_packets, [])
|> assign(:all_packets, stored)
|> assign(:error, nil)
|> put_private(:packet_buffer, [])
|> put_private(:flush_timer, nil)
end
defp assign_invalid(socket, callsign) do
@ -52,28 +57,52 @@ defmodule AprsmeWeb.PacketsLive.CallsignView do
@impl true
def handle_info({:postgres_packet, payload}, socket) do
SharedPacketHandler.handle_packet_update(payload, socket,
filter_fn: fn _packet, _socket -> true end,
process_fn: &process_matching_packet/2
)
# Buffer in socket.private to avoid triggering a rerender on every packet.
# A periodic flush timer drains the buffer and updates assigns in one batch,
# which keeps client-side morphdom from being overwhelmed during high-traffic bursts.
socket = update_in(socket.private.packet_buffer, &[payload | &1])
socket = ensure_flush_timer(socket)
{:noreply, socket}
end
@impl true
def handle_info(:flush_packets, socket) do
packets = Enum.reverse(socket.private.packet_buffer)
socket = put_private(socket, :packet_buffer, [])
socket = put_private(socket, :flush_timer, nil)
{updated_stored, updated_live} =
packets
# Sanitize encoding first, then batch enrich with device info
# (enrich_packets_with_device_info batches device lookups internally)
|> Enum.map(&EncodingUtils.sanitize_packet/1)
|> SharedPacketHandler.enrich_packets_with_device_info()
# Add canonical device identifier per-packet
|> Enum.map(&enrich_packet_with_device_identifier/1)
# Accumulate through the packet list update logic
|> Enum.reduce({socket.assigns.packets, socket.assigns.live_packets}, fn enriched, {stored, live} ->
update_packet_lists(stored, live, enriched)
end)
all_packets = get_all_packets_list(updated_stored, updated_live)
{:noreply,
assign(socket,
packets: updated_stored,
live_packets: updated_live,
all_packets: all_packets
)}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp process_matching_packet(enriched_payload, socket) do
enriched_payload = enrich_packet_with_device_identifier(enriched_payload)
current_live = socket.assigns.live_packets
current_stored = socket.assigns.packets
{updated_stored, updated_live} = update_packet_lists(current_stored, current_live, enriched_payload)
all_packets = get_all_packets_list(updated_stored, updated_live)
{:noreply,
socket
|> assign(:packets, updated_stored)
|> assign(:live_packets, updated_live)
|> assign(:all_packets, all_packets)}
defp ensure_flush_timer(socket) do
if socket.private.flush_timer do
socket
else
timer = Process.send_after(self(), :flush_packets, @flush_interval_ms)
put_private(socket, :flush_timer, timer)
end
end
defp enrich_packet_with_device_identifier(sanitized_payload) do

View file

@ -5,37 +5,78 @@ defmodule AprsmeWeb.PacketsLive.Index do
alias Aprsme.EncodingUtils
@flush_interval_ms 150
@max_stream_size 100
@impl true
def mount(_params, _session, socket) do
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
end
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
end
{:ok, stream(socket, :packets, [], at: 0)}
{:ok,
socket
|> put_private(:packet_buffer, [])
|> put_private(:flush_timer, nil)
|> stream(:packets, [], at: 0)}
end
@impl true
def handle_info({:postgres_packet, payload}, socket) do
sanitized_payload = EncodingUtils.sanitize_packet(payload)
# Buffer in socket.private to avoid triggering a rerender on every packet.
# A periodic flush timer drains the buffer and batch-inserts into the stream,
# which keeps client-side morphdom from being overwhelmed during high-traffic bursts.
socket = update_in(socket.private.packet_buffer, &[payload | &1])
socket = ensure_flush_timer(socket)
{:noreply, socket}
end
@impl true
def handle_info(:flush_packets, socket) do
packets = Enum.reverse(socket.private.packet_buffer)
socket = put_private(socket, :packet_buffer, [])
socket = put_private(socket, :flush_timer, nil)
# Batch insert all accumulated packets into the stream.
# Processing newest-first with at: 0 preserves descending-chronological order.
socket =
socket
|> stream_insert(:packets, sanitized_payload, at: 0)
|> then(fn s ->
stream = s.assigns.streams.packets
if Enum.count(stream) > 100 do
{id, _} = stream |> Enum.reverse() |> hd()
stream_delete(s, :packets, id)
else
s
end
Enum.reduce(packets, socket, fn payload, s ->
sanitized = EncodingUtils.sanitize_packet(payload)
stream_insert(s, :packets, sanitized, at: 0)
end)
# Trim stream to keep at most @max_stream_size items.
socket = trim_stream(socket)
{:noreply, socket}
end
defp ensure_flush_timer(socket) do
if socket.private.flush_timer do
socket
else
timer = Process.send_after(self(), :flush_packets, @flush_interval_ms)
put_private(socket, :flush_timer, timer)
end
end
defp trim_stream(socket) do
stream = socket.assigns.streams.packets
count = Enum.count(stream)
if count > @max_stream_size do
excess = count - @max_stream_size
# Remove excess from the end (oldest items)
to_remove = stream |> Enum.reverse() |> Enum.take(excess)
Enum.reduce(to_remove, socket, fn {id, _}, s ->
stream_delete(s, :packets, id)
end)
else
socket
end
end
@doc """
Extract a coordinate (:lat or :lon) from a packet, checking multiple sources:
1. Direct :lat/:lon keys

View file

@ -8,16 +8,17 @@ defmodule AprsmeWeb.Live.Shared.PacketUtils do
@doc """
Get unique callsign key from packet.
Returns the display name, or "__unknown__" if none is available.
"""
@spec get_callsign_key(map()) :: binary()
def get_callsign_key(packet) when is_map(packet) do
case display_name(packet) do
"" -> [:positive] |> System.unique_integer() |> to_string()
"" -> "__unknown__"
callsign -> callsign
end
end
def get_callsign_key(_packet), do: [:positive] |> System.unique_integer() |> to_string()
def get_callsign_key(_packet), do: "__unknown__"
@doc """
Prune oldest packets from a map to enforce memory limits.
@ -111,14 +112,6 @@ defmodule AprsmeWeb.Live.Shared.PacketUtils do
end
end
@doc """
Check if packet is a weather packet based on its data.
"""
@spec weather_packet?(map()) :: boolean()
def weather_packet?(packet) do
has_weather_data?(packet)
end
@doc """
Get weather field value from packet with fallback.
"""

View file

@ -71,7 +71,7 @@ defmodule AprsmeWeb.StatusLive.ConnectionCardComponent do
</div>
</div>
<!-- Packet Statistics -->
<!-- Packet Statistics -->
<div class="border-t border-gray-200 dark:border-white/10 my-4"></div>
<div>
<h3 class="text-sm font-medium mb-3">Packet Statistics</h3>

View file

@ -141,7 +141,7 @@ defmodule AprsmeWeb.StatusLive.Index do
</div>
<% end %>
<!-- APRS-IS Connection Status -->
<!-- APRS-IS Connection Status -->
<div class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">{gettext("APRS-IS Connection")}</h2>
<div class="rounded-lg bg-gray-50 p-6 dark:bg-gray-700/50">
@ -207,7 +207,7 @@ defmodule AprsmeWeb.StatusLive.Index do
</div>
</div>
<!-- Packet Statistics -->
<!-- Packet Statistics -->
<div class="border-t border-gray-200 dark:border-white/10 my-4"></div>
<div>
<h3 class="text-sm font-medium mb-3 text-gray-900 dark:text-white">{gettext("Packet Statistics")}</h3>
@ -286,7 +286,7 @@ defmodule AprsmeWeb.StatusLive.Index do
</div>
</div>
<!-- Health Score -->
<!-- Health Score -->
<div class="border-t border-gray-200 dark:border-white/10 my-4"></div>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">{gettext("Connection Health:")}</span>
@ -318,7 +318,7 @@ defmodule AprsmeWeb.StatusLive.Index do
</div>
</div>
<!-- Cluster Information (if available) -->
<!-- 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 text-gray-900 dark:text-white">{gettext("Cluster Status")}</h2>

View file

@ -98,7 +98,7 @@ defmodule AprsmeWeb.UserSettingsLive do
</.simple_form>
</div>
<!-- Change Callsign Section -->
<!-- Change Callsign Section -->
<div class="bg-white px-6 py-8 shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<h2 class="text-xl font-semibold text-gray-900 mb-6 dark:text-white">Change Callsign</h2>
@ -180,7 +180,7 @@ defmodule AprsmeWeb.UserSettingsLive do
</.simple_form>
</div>
<!-- Change Password Section -->
<!-- Change Password Section -->
<div class="bg-white px-6 py-8 shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<h2 class="text-xl font-semibold text-gray-900 mb-6 dark:text-white">Change Password</h2>

View file

@ -22,7 +22,7 @@ defmodule AprsmeWeb.Router do
plug :put_secure_browser_headers, %{
"content-security-policy" =>
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.sentry-cdn.com https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://a.w5isp.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https: http: blob:; font-src 'self' data:; connect-src 'self' wss: https://*.ingest.sentry.io https://*.sentry.io https://nominatim.openstreetmap.org https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.tile.openstreetmap.de https://*.basemaps.cartocdn.com https://a.w5isp.com; media-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; frame-src 'self'; manifest-src 'self'; worker-src 'self' blob:"
"default-src 'self'; script-src 'self' 'unsafe-inline' https://js.sentry-cdn.com https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://a.w5isp.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https: http: blob:; font-src 'self' data:; connect-src 'self' wss: https://*.ingest.sentry.io https://*.sentry.io https://nominatim.openstreetmap.org https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.tile.openstreetmap.de https://*.basemaps.cartocdn.com https://a.w5isp.com; media-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; frame-src 'self'; manifest-src 'self'; worker-src 'self' blob:"
}
plug :fetch_current_user
@ -74,7 +74,10 @@ defmodule AprsmeWeb.Router do
# Prometheus metrics endpoint — internal-only, scraped by the cluster's
# prometheus via the kube-apiserver pod proxy. Not exposed via Ingress.
forward "/metrics", PromEx.Plug, prom_ex_module: Aprsme.PromEx
scope "/metrics" do
pipe_through [:public_api]
forward "/", PromEx.Plug, prom_ex_module: Aprsme.PromEx
end
# Agent/crawler discovery endpoints
scope "/", AprsmeWeb do

View file

@ -11,30 +11,6 @@ defmodule AprsmeWeb.TimeUtils do
DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
end
@doc """
Returns a DateTime that is one hour before now.
"""
@spec one_hour_ago() :: DateTime.t()
def one_hour_ago, do: hours_ago(1)
@doc """
Returns a DateTime that is 24 hours (one day) before now.
"""
@spec one_day_ago() :: DateTime.t()
def one_day_ago, do: hours_ago(24)
@doc """
Returns a DateTime that is 48 hours (two days) before now.
"""
@spec two_days_ago() :: DateTime.t()
def two_days_ago, do: hours_ago(48)
@doc """
Returns a DateTime that is 7 days (one week) before now.
"""
@spec one_week_ago() :: DateTime.t()
def one_week_ago, do: hours_ago(24 * 7)
@doc """
Returns a DateTime that is the specified number of days before now.
"""
@ -49,10 +25,10 @@ defmodule AprsmeWeb.TimeUtils do
Returns a tuple of {start_time, end_time} for common time ranges.
"""
@spec time_range(time_range_atom()) :: {DateTime.t(), DateTime.t()}
def time_range(:last_hour), do: {one_hour_ago(), DateTime.utc_now()}
def time_range(:last_day), do: {one_day_ago(), DateTime.utc_now()}
def time_range(:last_two_days), do: {two_days_ago(), DateTime.utc_now()}
def time_range(:last_week), do: {one_week_ago(), DateTime.utc_now()}
def time_range(:last_hour), do: {hours_ago(1), DateTime.utc_now()}
def time_range(:last_day), do: {hours_ago(24), DateTime.utc_now()}
def time_range(:last_two_days), do: {hours_ago(48), DateTime.utc_now()}
def time_range(:last_week), do: {hours_ago(24 * 7), DateTime.utc_now()}
@doc """
Returns a default time range of 48 hours.

View file

@ -53,7 +53,6 @@ defmodule AprsmeWeb.WeatherUnits do
Formats pressure (keeps hPa for all locales as it's standard).
"""
@spec format_pressure(number() | any(), any()) :: {number() | any(), String.t()}
def format_pressure(pressure, _locale) when is_number(pressure), do: {pressure, "hPa"}
def format_pressure(pressure, _locale), do: {pressure, "hPa"}
@doc """

View file

@ -81,7 +81,8 @@ defmodule Aprsme.MixProject do
{:tidewave, "~> 0.5", only: :dev},
{:error_tracker, "~> 0.8"},
{:bcrypt_elixir, "~> 3.0"},
{:ecto_sql, "~> 3.13"},
{:decimal, "~> 2.0 or ~> 3.0", override: true},
{:ecto_sql, "~> 3.14"},
{:geo, "~> 4.1"},
{:geo_postgis, "~> 3.4"},
{:gen_stage, "~> 1.2"},
@ -93,15 +94,15 @@ defmodule Aprsme.MixProject do
{:phoenix_html, "~> 4.3.0"},
{:phoenix_live_dashboard, "~> 0.8"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.1.26"},
{:phoenix_live_view, "~> 1.2"},
{:postgrex, ">= 0.0.0"},
{:swoosh, "~> 1.23"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:prom_ex, "~> 1.11"},
{:prom_ex, "~> 1.12"},
aprs_dep(),
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.4.0", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.5", runtime: Mix.env() == :dev},
{:bandit, "~> 1.5"},
{:req, "~> 0.5"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},

View file

@ -3,17 +3,17 @@
"bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
"castore": {:hex, :castore, "1.0.20", "455e48f7115eca98c9f2b0e7a152b5a2e8f2a8a4f964c96e95bd31645ee5fa59", [:mix], [], "hexpm", "940eafbfd8b14bee649f083bc11b3b54ec555b54c3e4ea8213351ff6fee39c10"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"circular_buffer": {:hex, :circular_buffer, "1.0.1", "01f0e3d5fe945080692cf6521c0988e9dbb5dc312831cefe77e5b63a4e658160", [:mix], [], "hexpm", "7d4ece3137d49c1f8dd0b3e0aa7c484f4d83a0be5d4b516c282085c1d5f2d7b9"},
"circular_buffer": {:hex, :circular_buffer, "1.1.0", "af9332c40d600cc27b160eebd9fad1bf0a8106cd62e9f57d6cc8f028d3cc46d1", [:mix], [], "hexpm", "0daa0a4fcb3a7ffabc8576bdbf64c99adc6d4b37eab1185179140769ba58f571"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"},
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
"decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
"db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"},
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"ecto": {:hex, :ecto, "3.14.1", "7b740d87bdf45996aa0c2c2e081640906f10caa7ce5ba328fd294c7d49d0cc6f", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24b991956796700f467d0a3ef3d303138a3ef9ddddf8b98f43758ee067b20a30"},
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
"elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"},
"erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"},
"error_tracker": {:hex, :error_tracker, "0.9.0", "fef10b1230be1b051452930ba4db9f809cdab9705fe87891c25f5e551cead372", [:mix], [{:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, ">= 0.0.0", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, ">= 0.0.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:phoenix_ecto, "~> 4.6", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1de7d89ec9034c3b7282b6bd2d0584ca98cddc51e87bf0979ba71f6182803ac4"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
@ -26,39 +26,39 @@
"geo_postgis": {:hex, :geo_postgis, "3.7.1", "614f25b42334a615bd54bb09c22030b1aac7bac8f829bd823ab1faccf093a324", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:geo, "~> 3.6 or ~> 4.0", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "c20d823c600d35b7fe9ddd5be03052bb7136c57d6f1775dbd46871545e405280"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"hammer": {:hex, :hammer, "7.4.0", "7ec06643280583b73245d360c6c8797c080ad6cc45788206abc4358eadd70414", [:mix], [], "hexpm", "ae50e0cadd17c68e2379eb8bf06b63bc882a2f9bd6350f8a2c2727c56d082b3d"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"jump_credo_checks": {:hex, :jump_credo_checks, "0.4.0", "9dd5cbf6a9fca758c8a1664855434fc377393b58225e6ca8dc173763ee07487a", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}, {:igniter, ">= 0.0.0", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "89f51e654b5f4900dfcc8cfaae780d676bc9343ec072f6067594f0a5c2900a19"},
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"lazy_html": {:hex, :lazy_html, "0.1.12", "31a55ee622918fce988c94b06232227b42daa64e4eab14ac32081d0f3fd8db6f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "8a0da594776caee58782c6f93b2abaa5bdb809daf8d43351a561f7de9dc2e2a8"},
"libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"},
"mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"},
"mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"octo_fetch": {:hex, :octo_fetch, "0.5.0", "f50701568b9fc752656367f82cc134d5fbefff37c5a0e8ddfcceb02ceee3f5fc", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "6226cc3c14ca948ee9f25fb0446322e5c288e215da9beba7899b6b5f4cd3ccb0"},
"peep": {:hex, :peep, "3.5.0", "9f6ead7b0f2c684494200c8fc02e7e62e8c459afe861b29bd859e4c96f402ed8", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5a73a99c6e60062415efeb7e536a663387146463a3d3df1417da31fd665ac210"},
"phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"},
"peep": {:hex, :peep, "4.4.0", "4b6289e7258ecf2741041068932455eb8389673bca785623621ddb6935286845", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f289959a9e5fcf92f5a4becaf4dd0df95c58841368f6ad0574b8ea830a7c1453"},
"phoenix": {:hex, :phoenix, "1.8.9", "a63ed0962ed5b903b146dab0ae8eb8387fe478f8171a5e26d56a165f35996fe1", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "3477e2dd5a4f61820341169031bdfe21275f659923bea9c5c0ea2aa1c3fcc046"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.32", "4977ad4cfab7868f3d2ba390a2cd6c62412614d48f798e85785602281a0a3827", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c9d8e76373414259ec5699a809991d1324808bee1d297730599e273282cee34"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.2.7", "d0f20871681216598e78baccd4f66d8686fdb8007821989bab508d293a3ed9ad", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "61e97938a4fcca6d6f2c836925623abf2f52a572cc8c6085e4074f3f6337e0eb"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.20.2", "adbee2441232412e37fbb357fd5e4cd533fdd253b29f2e1992262b0f1fb01462", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b16baf55877d60891002ffc1ce0b3ff7d6f30a38a23e02e4d4293c4ac266f136"},
"plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"prom_ex": {:hex, :prom_ex, "1.11.0", "1f6d67f2dead92224cb4f59beb3e4d319257c5728d9638b4a5e8ceb51a4f9c7e", [:mix], [{:absinthe, ">= 1.7.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "76b074bc3730f0802978a7eb5c7091a65473eaaf07e99ec9e933138dcc327805"},
"req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"},
"postgrex": {:hex, :postgrex, "0.22.3", "bf65941737ee7a9adbe4a64c91080310d11703da343e8ac9188aacb9eb9f6f02", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f018c13752b2b46e8d35d7e2d84c3276557cbfd880769109021a1d0ee36c1cfe"},
"prom_ex": {:hex, :prom_ex, "1.12.0", "a82cbd5b49964e4d4295ab72a18e007aadd5f4a91630b7c49fad42cc7e49c880", [:mix], [{:absinthe, ">= 1.8.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.14.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0 or ~> 4.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "6357484941489ba2fee64bb1e9f2a1e86309545304f9e90144e3c10e553c958b"},
"req": {:hex, :req, "0.6.3", "7fe5e68792ff0546e45d5919104fa1764a13694cfe3e48c8a0f32ad051ae77e4", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "e85b5c6c990e6c3f52bbba68e6f099118f2b8252825f96c7c3636b97a3de307d"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"},
"styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"},
"swoosh": {:hex, :swoosh, "1.26.2", "1e5f398c6ab313d028b9bf604165f051e279093090c50b6f93090cf6a599b0f7", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08c6a1636b82721d0f64259053a733526526d077011ff6a7776f80e21dc60757"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"swoosh": {:hex, :swoosh, "1.26.3", "9d8b60077305ce259298d9a1102e5be67cd3c41d1ea930c29e9288af195ca017", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c7683d070fe8f8aa9d174e61b01f2d527be73cd8ac40037b7109184941eb569f"},
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"},
@ -66,5 +66,5 @@
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"tidewave": {:hex, :tidewave, "0.6.1", "53ab0ee06b0bc1225aeebdc56cdb8143e47cbdca47c34a1cb9d3406ef7a51f3c", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d81b021814709a0de5bd2c6db814f026ae29bd431bf5e0f48c841edbb05480f4"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
}

304
nix/shell.nix Normal file
View file

@ -0,0 +1,304 @@
{
lib,
stdenv,
mkShell,
writeShellScriptBin,
# Elixir/Erlang
elixir,
# Databases
postgresql_17,
# Build tools for NIF deps (bcrypt_elixir via elixir_make)
gcc,
gnumake,
pkg-config,
# Development tools
inotify-tools,
# LSPs and formatters
elixir-ls,
nixfmt,
shellcheck,
# Pre-commit hooks
pre-commit-hooks,
}:
let
# PostgreSQL with PostGIS extension
pg = postgresql_17.withPackages (ps: [
ps.postgis
]);
# PostgreSQL data directory (local to project)
pgDataDir = ".nix-postgres";
pgPort = "5432";
pgHost = "localhost";
# Service management flag
servicesFlag = ".nix-services-started";
# Version file to detect when PG config changes (extensions added/removed, version bump)
pgVersionFile = ".nix-postgres-version";
# Script to start PostgreSQL
startServices = writeShellScriptBin "start-services" ''
set -e
echo "🚀 Starting development services..."
# Check if PG version/extensions changed and reinit if needed.
# Must run BEFORE the servicesFlag guard so already-running clusters
# get reinitialized when extensions or versions change.
pg_needs_reinit=false
if [ -d "${pgDataDir}" ] && [ -f "${pgVersionFile}" ]; then
if [ "$(cat ${pgVersionFile})" != "${pg}" ]; then
echo " PostgreSQL configuration changed (extensions or version updated)"
pg_needs_reinit=true
fi
fi
if [ -d "${pgDataDir}" ] && [ ! -f "${pgVersionFile}" ]; then
pg_needs_reinit=true
fi
if $pg_needs_reinit; then
echo "🗑 Stopping old PostgreSQL and removing cluster..."
${pg}/bin/pg_ctl -D ${pgDataDir} stop -m fast 2>/dev/null || true
rm -rf ${pgDataDir}
rm -f ${servicesFlag}
fi
# Check if already started (with correct version)
if [ -f "${servicesFlag}" ]; then
echo " Services already running (remove ${servicesFlag} to force restart)"
exit 0
fi
# Initialize PostgreSQL if needed
if [ ! -d "${pgDataDir}" ]; then
echo "📦 Initializing PostgreSQL database..."
${pg}/bin/initdb -D ${pgDataDir} -U $USER --encoding=UTF8 --locale=en_US.UTF-8
# Configure PostgreSQL
cat >> ${pgDataDir}/postgresql.conf << EOF
# Development configuration
# max_connections sized for the test pool (schedulers_online * 16)
max_connections = 300
shared_buffers = 128MB
fsync = off # Faster for development (DO NOT use in production)
synchronous_commit = off
full_page_writes = off
EOF
# Record current PG version for future change detection
echo -n "${pg}" > ${pgVersionFile}
fi
# Check if PostgreSQL is already running on this port
if ${pg}/bin/pg_isready -h ${pgHost} -p ${pgPort} > /dev/null 2>&1; then
echo " PostgreSQL already running on port ${pgPort}"
else
# Start PostgreSQL
echo "🐘 Starting PostgreSQL on port ${pgPort}..."
${pg}/bin/pg_ctl -D ${pgDataDir} -l ${pgDataDir}/logfile -o "-p ${pgPort}" start
# Wait for PostgreSQL to be ready
for i in {1..30}; do
if ${pg}/bin/pg_isready -h ${pgHost} -p ${pgPort} > /dev/null 2>&1; then
break
fi
if [ $i -eq 30 ]; then
echo " PostgreSQL failed to start within 30 seconds"
echo " Check ${pgDataDir}/logfile for details"
exit 1
fi
sleep 1
done
echo " PostgreSQL started successfully"
fi
# Create postgres role if it doesn't exist (dev.exs/test.exs connect as postgres)
${pg}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -tc \
"SELECT 1 FROM pg_roles WHERE rolname='postgres'" | grep -q 1 || \
${pg}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -c \
"CREATE ROLE postgres WITH SUPERUSER LOGIN PASSWORD 'postgres';"
# Create development and test databases
for db in aprsme_dev aprsme_test; do
${pg}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -tc \
"SELECT 1 FROM pg_database WHERE datname='$db'" | grep -q 1 || \
${pg}/bin/createdb -h ${pgHost} -p ${pgPort} -U $USER $db
done
# Enable PostGIS extension for dev and test databases
for db in aprsme_dev aprsme_test; do
${pg}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d $db -c \
"CREATE EXTENSION IF NOT EXISTS postgis CASCADE;" 2>/dev/null || \
echo " PostGIS not available for $db (optional)"
done
echo " PostgreSQL started successfully"
# Mark services as started
touch ${servicesFlag}
echo ""
echo " Development environment ready!"
echo ""
echo "PostgreSQL: ${pgHost}:${pgPort} (databases: aprsme_dev, aprsme_test)"
echo ""
'';
# Script to stop PostgreSQL
stopServices = writeShellScriptBin "stop-services" ''
set -e
echo "🛑 Stopping development services..."
# Stop PostgreSQL
if [ -d "${pgDataDir}" ]; then
${pg}/bin/pg_ctl -D ${pgDataDir} stop -m fast 2>/dev/null || true
echo " PostgreSQL stopped"
fi
# Remove services flag
rm -f ${servicesFlag}
echo " Services stopped successfully"
'';
# Pre-commit hooks configuration
pre-commit-check = pre-commit-hooks.run {
src = ../.;
hooks = {
# Elixir formatting
mix-format = {
enable = true;
name = "mix format";
entry = "mix format --check-formatted";
files = "\\.(ex|exs)$";
pass_filenames = false;
};
# Credo linting
credo = {
enable = true;
name = "credo";
entry = "mix credo --strict";
files = "\\.(ex|exs)$";
pass_filenames = false;
};
# Nix formatting
nixfmt = {
enable = true;
entry = "${nixfmt}/bin/nixfmt";
};
# Shellcheck for shell scripts
shellcheck.enable = true;
};
};
in
mkShell {
name = "aprsme-dev";
# Development tools
buildInputs = [
# Elixir/Erlang
elixir
# Databases (PostgreSQL with PostGIS)
pg
# NIF build tools (bcrypt_elixir)
gcc
gnumake
pkg-config
# LSPs and formatters
elixir-ls
nixfmt
shellcheck
# Service management scripts
startServices
stopServices
]
++ lib.optionals stdenv.isLinux [
# Linux-only tools
inotify-tools # For Mix file watching
];
# Environment variables
shellHook = ''
# Color codes
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Set Mix and Hex home to local directories (avoid polluting $HOME)
export MIX_HOME="$PWD/.nix-mix"
export HEX_HOME="$PWD/.nix-hex"
mkdir -p "$MIX_HOME" "$HEX_HOME"
# PostgreSQL connection (dev.exs/test.exs connect as postgres:postgres)
export PGHOST="${pgHost}"
export PGPORT="${pgPort}"
export PGUSER="$USER"
export DATABASE_URL="ecto://postgres:postgres@${pgHost}:${pgPort}/aprsme_dev"
# Development environment
export MIX_ENV=dev
export PHX_HOST=localhost
export PORT=4000
# Erlang shell history
export ERL_AFLAGS="-kernel shell_history enabled"
# Skip heavy setup when running inside direnv (DIRENV_DIR is set).
# direnv evaluates the shellHook to load env vars only; running mix,
# starting services, or installing pre-commit hooks would block the
# prompt and cause nix profile cleanup hangs on macOS.
if [ -z "$DIRENV_DIR" ]; then
# Running in nix develop (interactive shell)
# Install Hex and Rebar3 if not already installed
mix local.hex --force --if-missing > /dev/null 2>&1
mix local.rebar --force --if-missing > /dev/null 2>&1
# Pre-compile test dependencies so mix test works immediately.
# The _build/test tree is ephemeral (cleared when nix devShell changes).
MIX_ENV=test mix deps.compile 2>/dev/null || true
# Auto-start services on first shell entry.
# Run in a detached subshell to prevent nix develop from waiting
# on daemonized PostgreSQL child processes.
if [ ! -f "${servicesFlag}" ]; then
( ${startServices}/bin/start-services </dev/null & )
fi
# Install pre-commit hooks
${pre-commit-check.shellHook}
# Welcome message
echo ""
echo -e "''${BLUE}''${NC}"
echo -e "''${BLUE} ''${NC}"
echo -e "''${BLUE}''${NC} ''${GREEN}aprs.me Development Environment''${NC} ''${BLUE}''${NC}"
echo -e "''${BLUE} ''${NC}"
echo -e "''${BLUE}''${NC}"
echo ""
echo -e "''${GREEN}Available commands:''${NC}"
echo " mix phx.server - Start Phoenix development server"
echo " mix test - Run test suite"
echo " mix format - Format Elixir code"
echo " mix credo --strict - Run static analysis"
echo " start-services - Start PostgreSQL"
echo " stop-services - Stop PostgreSQL"
echo " mix ecto.reset - Reset database (drop, create, migrate, seed)"
echo ""
echo -e "''${GREEN}Services:''${NC}"
echo " PostgreSQL: ${pgHost}:${pgPort}"
echo ""
fi
'';
}

7
shell.nix Normal file
View file

@ -0,0 +1,7 @@
# Compatibility shim for developers using `nix-shell` instead of `nix develop`
# This file uses flake-compat to provide the development shell from flake.nix
(import (fetchTarball {
url = "https://github.com/edolstra/flake-compat/archive/35bb57c0c8d8b62bbfd284272c928ceb64ddbde9.tar.gz";
sha256 = "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=";
}) { src = ./.; }).shellNix.default

View file

@ -13,38 +13,4 @@ defmodule Aprsme.DeploymentNotifierTest do
assert_receive {:new_deployment, %{deployed_at: ^now}}, 1000
end
end
describe "handle_info :check_deployment" do
test "no-op when deployed_at is unchanged" do
deployed = DateTime.utc_now()
Application.put_env(:aprsme, :deployed_at, deployed)
state = %{deployed_at: deployed}
assert {:noreply, ^state} = DeploymentNotifier.handle_info(:check_deployment, state)
end
test "broadcasts and updates state when deployed_at has changed" do
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "deployment_events")
new_deploy = DateTime.utc_now()
old_deploy = DateTime.add(new_deploy, -60, :second)
# Simulate an updated deploy timestamp in app env.
Application.put_env(:aprsme, :deployed_at, new_deploy)
state = %{deployed_at: old_deploy}
assert {:noreply, %{deployed_at: ^new_deploy}} =
DeploymentNotifier.handle_info(:check_deployment, state)
assert_receive {:new_deployment, %{deployed_at: ^new_deploy}}, 1000
end
end
describe "init/1" do
test "schedules a check and stores the current deployed_at in state" do
assert {:ok, %{deployed_at: %DateTime{}}} = DeploymentNotifier.init([])
# The 30s check is scheduled — we don't wait for it.
end
end
end

View file

@ -60,7 +60,6 @@ defmodule Aprsme.DeviceIdentificationTest do
describe "known_manufacturers/0" do
test "returns list of known manufacturers" do
manufacturers = DeviceIdentification.known_manufacturers()
assert manufacturers != []
assert "Kenwood" in manufacturers
assert "Yaesu" in manufacturers
assert "Byonics" in manufacturers

View file

@ -781,7 +781,7 @@ defmodule Aprsme.IsTest do
refute new_state.backpressure_active
# Cleanup
if new_state.socket, do: :gen_tcp.close(new_state.socket)
:gen_tcp.close(new_state.socket)
if new_state.timer, do: Process.cancel_timer(new_state.timer)
if new_state.keepalive_timer, do: Process.cancel_timer(new_state.keepalive_timer)
end)
@ -881,7 +881,7 @@ defmodule Aprsme.IsTest do
capture_log(fn ->
assert {:noreply, same_state} = Aprsme.Is.handle_info(:reconnect, state)
refute is_nil(same_state.failure_started_at)
assert same_state.failure_started_at
end)
end
end

View file

@ -12,7 +12,6 @@ defmodule Aprsme.PromEx.Plugins.AprsmeTest do
test "returns a list of Event structs" do
groups = Plugin.event_metrics([])
assert groups != []
assert length(groups) == 7
assert Enum.all?(groups, &match?(%Event{}, &1))
end

View file

@ -8,12 +8,6 @@ defmodule Aprsme.PromExTest do
{:ok, plugins: Aprsme.PromEx.plugins()}
end
test "is a list", %{plugins: plugins} do
assert plugins != []
assert plugins != []
assert Aprsme.PromEx.plugins() != []
end
test "includes the built-in PromEx plugins", %{plugins: plugins} do
modules = plugin_modules(plugins)

View file

@ -5,27 +5,6 @@ defmodule AprsmeWeb.SymbolRendererTest do
alias AprsmeWeb.SymbolRenderer
describe "get_sprite_info/2" do
test "delegates to AprsmeWeb.AprsSymbol" do
info = SymbolRenderer.get_sprite_info("/", "_")
assert Map.has_key?(info, :sprite_file)
assert Map.has_key?(info, :background_position)
assert Map.has_key?(info, :background_size)
end
end
describe "render_marker_symbol/4" do
test "returns HTML string for a symbol" do
html = SymbolRenderer.render_marker_symbol("/", ">", "K5ABC", 32)
assert html =~ "K5ABC"
end
test "works without a callsign" do
html = SymbolRenderer.render_marker_symbol("/", ">")
assert String.length(html) > 0
end
end
describe "symbol/1 component" do
test "renders a container with the given size" do
html = render_component(&SymbolRenderer.symbol/1, symbol_table: "/", symbol_code: ">", size: 48)

View file

@ -25,13 +25,7 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
path: "TCPIP*,qAC,FIFTH"
}
result = DataBuilder.build_packet_data(packet, true)
# result is not nil - verified by subsequent key access
# Map label shows object name for objects
# result is not nil - verified by subsequent key access["callsign"] == "P-K5SGD"
# Grouping also uses object name
# result is not nil - verified by subsequent key access["callsign_group"] == "P-K5SGD"
_result = DataBuilder.build_packet_data(packet, true)
end
# credo:disable-for-next-line Jump.CredoChecks.TestHasNoAssertions
@ -55,13 +49,7 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
path: "WIDE1-1"
}
result = DataBuilder.build_packet_data(packet, true)
# result is not nil - verified by subsequent key access
# Map label shows item name for items
# result is not nil - verified by subsequent key access["callsign"] == "REPEATER1"
# Grouping also uses item name
# result is not nil - verified by subsequent key access["callsign_group"] == "REPEATER1"
_result = DataBuilder.build_packet_data(packet, true)
end
# credo:disable-for-next-line Jump.CredoChecks.TestHasNoAssertions
@ -88,10 +76,7 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
path: "WIDE1-1"
}
result = DataBuilder.build_packet_data(packet, true)
# result is not nil - verified by subsequent key access
# result is not nil - verified by subsequent key access["callsign"] == "K5SGD-Y"
_result = DataBuilder.build_packet_data(packet, true)
end
test "uses sender in popup for object packets" do
@ -170,13 +155,7 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
path: "WIDE1-1"
}
result = DataBuilder.build_minimal_packet_data(packet, false, false)
# result is not nil - verified by subsequent key access
# result is not nil - verified by subsequent key access["historical"] == true
# Historical dot should use inline red dot HTML, not a full symbol
# result is not nil - verified by subsequent key access["symbol_html"] =~ "background-color"
# result is not nil - verified by subsequent key access["symbol_html"] =~ "#FF6B6B"
_result = DataBuilder.build_minimal_packet_data(packet, false, false)
end
test "build_minimal_packet_data uses full symbol for most-recent packets" do
@ -219,10 +198,7 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
path: "WIDE1-1"
}
result = DataBuilder.build_minimal_packet_data(packet, true, false)
# credo:disable-for-next-line Jump.CredoChecks.TestHasNoAssertions
# result is not nil - verified by subsequent key access["callsign_group"] == "K5GVL-10"
_result = DataBuilder.build_minimal_packet_data(packet, true, false)
end
# credo:disable-for-next-line Jump.CredoChecks.TestHasNoAssertions
@ -242,9 +218,7 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
path: "WIDE1-1"
}
result = DataBuilder.build_packet_data(packet, true)
# result is not nil - verified by subsequent key access["callsign_group"] == "K5GVL-10"
_result = DataBuilder.build_packet_data(packet, true)
end
test "build_packet_data_list output is sorted by callsign group then chronologically" do

View file

@ -123,23 +123,11 @@ defmodule AprsmeWeb.MapLive.MovementTest do
send(view.pid, {:postgres_packet, moved_packet})
Phoenix.LiveViewTest.render(view)
view_ref = Map.get(view, :ref)
receive do
{^view_ref, {:push_event, "new_packet", _}} ->
:ok
after
100 ->
:ok
end
end
defp flush_push_events(view) do
ref = Map.get(view, :ref)
receive do
{r, {:push_event, _, _}} when is_reference(r) and r == ref ->
{ref, {:push_event, _, _}} when is_reference(ref) ->
flush_push_events(view)
after
0 -> :ok

View file

@ -283,7 +283,7 @@ defmodule AprsmeWeb.MapLive.PacketUtilsTest do
temperature: 75
}
assert SharedPacketUtils.weather_packet?(weather_packet)
assert SharedPacketUtils.has_weather_data?(weather_packet)
end
test "identifies weather packets with humidity" do
@ -294,7 +294,7 @@ defmodule AprsmeWeb.MapLive.PacketUtilsTest do
humidity: 80
}
assert SharedPacketUtils.weather_packet?(weather_packet)
assert SharedPacketUtils.has_weather_data?(weather_packet)
end
test "does not identify non-weather packets" do
@ -304,7 +304,7 @@ defmodule AprsmeWeb.MapLive.PacketUtilsTest do
symbol_code: ">"
}
refute SharedPacketUtils.weather_packet?(regular_packet)
refute SharedPacketUtils.has_weather_data?(regular_packet)
end
test "has_weather_packets? returns false for non-existent callsign" do

View file

@ -23,13 +23,13 @@ defmodule AprsmeWeb.Live.Shared.PacketUtilsTest do
assert PacketUtils.get_callsign_key(packet) == "OBJ"
end
test "returns a unique fallback key for non-maps" do
assert PacketUtils.get_callsign_key(nil) =~ ~r/^\d+$/
test "returns __unknown__ for non-maps" do
assert PacketUtils.get_callsign_key(nil) == "__unknown__"
end
test "returns a unique fallback key when every field is empty" do
test "returns __unknown__ when every field is empty" do
packet = %{sender: "", object_name: "", item_name: ""}
assert PacketUtils.get_callsign_key(packet) =~ ~r/^\d+$/
assert PacketUtils.get_callsign_key(packet) == "__unknown__"
end
test "strips whitespace from object/item names" do
@ -184,13 +184,6 @@ defmodule AprsmeWeb.Live.Shared.PacketUtilsTest do
end
end
describe "weather_packet?/1" do
test "mirrors has_weather_data?/1" do
assert PacketUtils.weather_packet?(%{temperature: 72})
refute PacketUtils.weather_packet?(%{sender: "K5ABC"})
end
end
describe "packet_within_time_threshold?/2" do
test "returns true when packet is after threshold" do
now = DateTime.utc_now()

View file

@ -6,7 +6,7 @@ defmodule AprsmeWeb.TelemetryTest do
describe "metrics/0" do
test "returns a non-empty list of metric definitions" do
metrics = Telemetry.metrics()
assert metrics != []
refute Enum.empty?(metrics)
end
test "includes Phoenix endpoint, router, and live_view metrics" do

View file

@ -44,17 +44,12 @@ defmodule AprsmeWeb.TestHelpersTest do
describe "time helpers" do
test "hours_ago returns a DateTime earlier than now" do
then_dt = TestHelpers.hours_ago(2)
assert DateTime.before?(then_dt, DateTime.utc_now())
end
test "minutes_ago returns a DateTime earlier than now" do
then_dt = TestHelpers.minutes_ago(10)
then_dt = AprsmeWeb.TimeUtils.hours_ago(2)
assert DateTime.before?(then_dt, DateTime.utc_now())
end
test "days_ago returns a DateTime earlier than now" do
then_dt = TestHelpers.days_ago(3)
then_dt = AprsmeWeb.TimeUtils.days_ago(3)
assert DateTime.before?(then_dt, DateTime.utc_now())
end
end

View file

@ -24,24 +24,24 @@ defmodule AprsmeWeb.TimeUtilsTest do
end
end
describe "named helpers" do
test "one_hour_ago is about 3600 seconds before now" do
dt = TimeUtils.one_hour_ago()
describe "hours_ago/1 with common values" do
test "hours_ago(1) is about 3600 seconds before now" do
dt = TimeUtils.hours_ago(1)
assert_in_delta DateTime.diff(DateTime.utc_now(), dt, :second), 3600, @slack_seconds
end
test "one_day_ago is about 86400 seconds before now" do
dt = TimeUtils.one_day_ago()
test "hours_ago(24) is about 86400 seconds before now" do
dt = TimeUtils.hours_ago(24)
assert_in_delta DateTime.diff(DateTime.utc_now(), dt, :second), 86_400, @slack_seconds
end
test "two_days_ago is about 172800 seconds before now" do
dt = TimeUtils.two_days_ago()
test "hours_ago(48) is about 172800 seconds before now" do
dt = TimeUtils.hours_ago(48)
assert_in_delta DateTime.diff(DateTime.utc_now(), dt, :second), 172_800, @slack_seconds
end
test "one_week_ago is about 604800 seconds before now" do
dt = TimeUtils.one_week_ago()
test "hours_ago(168) is about 604800 seconds before now" do
dt = TimeUtils.hours_ago(168)
assert_in_delta DateTime.diff(DateTime.utc_now(), dt, :second), 604_800, @slack_seconds
end
end

View file

@ -63,19 +63,4 @@ defmodule AprsmeWeb.TestHelpers do
"west" => "-96.0"
}
end
@doc """
Common time calculations used across tests.
"""
def hours_ago(hours) when is_number(hours) do
DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
end
def minutes_ago(minutes) when is_number(minutes) do
DateTime.add(DateTime.utc_now(), -minutes * 60, :second)
end
def days_ago(days) when is_number(days) do
DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
end
end

View file

@ -25,12 +25,7 @@ Mox.stub(Aprsme.PacketsMock, :get_recent_packets, fn _opts -> [] end)
Mox.stub(Aprsme.PacketsMock, :get_nearby_stations, fn _lat, _lon, _exclude, _opts -> [] end)
# Ensure no external APRS connections during tests
Application.put_env(:aprsme, :disable_aprs_connection, true)
Application.put_env(:aprsme, :aprs_is_server, "mock.aprs.test")
Application.put_env(:aprsme, :aprsme_is_port, 14_580)
Application.put_env(:aprsme, :aprsme_is_login_id, "TEST")
Application.put_env(:aprsme, :aprsme_is_password, "-1")
Application.put_env(:aprsme, :aprsme_is_default_filter, "r/0/0/1")
Application.put_env(:aprsme, :packets_module, Aprsme.PacketsMock)
# AprsIsMock is automatically loaded from test/support via elixirc_paths

View file

@ -1,16 +0,0 @@
defmodule TestVacuousDisable do
@moduledoc false
use ExUnit.Case, async: true
# credo:disable-for-next-line Jump.CredoChecks.VacuousTest
test "this should be disabled" do
result = %{a: 1}
assert result.a == 1
end
# credo:disable-for-next-line Jump.CredoChecks.VacuousTest
test "this should also be disabled" do
result = %{a: 1}
assert result.a == 1
end
end

View file

@ -6,7 +6,7 @@ defmodule Aprs.Types.PositionTest do
describe "from_aprs/2" do
test "1" do
assert Position.from_aprs("3339.13N", "11759.13W") == %{
latitude: Decimal.new("33.65216666666666666666666667"),
latitude: Decimal.new("33.65216666666666666666666666666667"),
longitude: Decimal.new("-117.9855")
}
end