Compare commits
10 commits
088fd20711
...
7e1498bf4e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1498bf4e | |||
|
|
0e322a2403 | ||
|
|
1006fdaefc | ||
|
|
243a98df77 | ||
|
|
b88eb9e584 | ||
|
|
bb73c17ce6 | ||
| 77d2a6ca30 | |||
| 281aef095c | |||
| 7bdf21fb53 | |||
| 62bbeaadb3 |
66 changed files with 1833 additions and 514 deletions
3
.envrc
Normal file
3
.envrc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# shellcheck shell=bash
|
||||
use flake
|
||||
source_env .envrc.local
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
erlang 29.0.1
|
||||
erlang 29.0.3
|
||||
elixir 1.20.2-otp-29
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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
41
docs/README.md
Normal 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
|
||||
66
docs/architecture/ingestion-pipeline.md
Normal file
66
docs/architecture/ingestion-pipeline.md
Normal 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
|
||||
92
docs/architecture/overview.md
Normal file
92
docs/architecture/overview.md
Normal 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
73
docs/contexts/accounts.md
Normal 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
51
docs/contexts/devices.md
Normal 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
100
docs/contexts/packets.md
Normal 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
129
docs/development/setup.md
Normal 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.
|
||||
73
docs/infrastructure/deployment.md
Normal file
73
docs/infrastructure/deployment.md
Normal 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.
|
||||
65
docs/infrastructure/observability.md
Normal file
65
docs/infrastructure/observability.md
Normal 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
56
docs/web/api-rest.md
Normal 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
28
docs/web/channels.md
Normal 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
81
docs/web/liveview-map.md
Normal 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.
|
||||
88
docs/web/liveview-pages.md
Normal file
88
docs/web/liveview-pages.md
Normal 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
124
flake.lock
generated
Normal 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
73
flake.nix
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ defmodule Aprsme.WeatherCache do
|
|||
The table is typically created in application.ex alongside other ETS tables.
|
||||
"""
|
||||
def setup do
|
||||
_table =
|
||||
if :ets.whereis(@cache_name) == :undefined do
|
||||
:ets.new(@cache_name, [
|
||||
:named_table,
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,6 +27,7 @@ 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
|
||||
|
|
@ -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,
|
||||
defp ensure_flush_timer(socket) do
|
||||
if socket.private.flush_timer do
|
||||
socket
|
||||
|> assign(:packets, updated_stored)
|
||||
|> assign(:live_packets, updated_live)
|
||||
|> assign(:all_packets, all_packets)}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
{: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)
|
||||
|
||||
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
|
||||
# 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 =
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
9
mix.exs
9
mix.exs
|
|
@ -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},
|
||||
|
|
|
|||
42
mix.lock
42
mix.lock
|
|
@ -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
304
nix/shell.nix
Normal 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
7
shell.nix
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue