Compare commits

...

10 commits

Author SHA1 Message Date
51678e0fb9
fix: two test issues causing 37 failures
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 39s
1. RoverPathProfileWorker sandbox ownership: fallback_hits/2 used
   Task.async_stream spawning separate DB-querying processes that
   lacked Ecto sandbox ownership in test mode. Replaced with
   sequential Enum.map since miss list is ≤9 points — no meaningful
   perf impact and eliminates the sandbox race entirely.

2. PSKR client test: asserted '6m' band in defaults, but the actual
   microwave band name is '6cm'. Fixed assertion.
2026-07-22 16:37:17 -05:00
ca842e3add
feat(admin): beacon monitor reassign, token regen, assigned_by display, user monitor management
Uncommitted work from previous session:
- Add list_users_select/0 helper for admin select dropdowns
- Add regenerate_token/1 context function
- Preload assigned_by association on monitor queries
- Add admin nav link to beacon monitors
- Fix JS event targets (remove redundant target: @myself)
- Add reassign user form to admin monitor show page
- Add regenerate token button to admin monitor show page
- Show assigned_by admin on monitor detail page
- Add beacon monitors section to user management edit page
- Add unassign monitor capability from user management
- Add full test coverage for all new functionality
- Fix pre-existing /account route warning (redirect → /users/settings)
2026-07-22 16:27:21 -05:00
f35da3a935
beacon monitor work 2026-07-22 10:30:21 -05:00
Graham McInitre
49ade78766 fix: wire pending_edits_query as data_provider for contact edit review table
The LiveTable on /admin/contact-edits used the bare ContactEdit schema
as its data source, which caused three symptoms:
- '0 pending' counter but stale approved/rejected edits still visible
- Blank contact/submitted-by cells (select_columns stripped preloaded
  associations, cell renderers received flat maps with no :contact/:user)
- Approve/reject didn't remove the row from the table

Fix: assign {Radio, :pending_edits_query, []} as the data_provider in
mount so handle_params threads it to stream_resources. The query variant
of list_resources preserves preloaded associations and includes the
WHERE status = :pending filter.

Added two tests that verify the table rendering and edit removal.
2026-07-22 08:54:46 -05:00
fb49eb016d
feat(monitors): schema migration + remove user self-service creation
- Add hardware/config fields migration to beacon_monitors table
- Update BeaconMonitor schema with provision/config changesets
- Add context functions: create_hardware, update_config, list_all_monitors
- Remove user-facing monitor creation (browser POST + API POST)
- Update settings page: show assigned monitors table with hardware info
- Update profile page: show assigned monitors, remove register links
- Fix all tests to match new API
2026-07-21 18:28:51 -05:00
739984d3bc
feat(profile): show beacon monitors section on own user profile page
Add a Beacon monitors card to /u/:callsign that appears only when the
viewer is the profile owner. Shows registered monitors with truncated
token and last-seen timestamp, with a link to manage monitors in
settings. Includes tests for visibility rules.
2026-07-21 18:08:05 -05:00
Graham McInitre
c193f35a0c k8s/argo optimizations 2026-07-21 12:14:01 -05:00
Graham McInitre
5c6cef2227 refactor: DRY token verification — extract shared verify_hashed_token_query helper
Consolidate verify_magic_link/confirm/password_reset_token_query into
one-line delegations to a private helper, removing 29 lines of
duplicated decode/hash/query logic.
2026-07-21 11:18:15 -05:00
Graham McInitre
aa6f2ea647 fix: run all precommit targets via Makefile, exit with error at end if any fail 2026-07-21 11:15:30 -05:00
Graham McInitre
343c8ea339 fix: use Makefile for precommit — each step runs in isolated shell with MIX_ENV=test 2026-07-21 11:08:45 -05:00
44 changed files with 2081 additions and 295 deletions

21
Makefile Normal file
View file

@ -0,0 +1,21 @@
.PHONY: precommit format test deps credo
precommit:
@EXIT=0; \
$(MAKE) format || EXIT=1; \
$(MAKE) test || EXIT=1; \
$(MAKE) deps || EXIT=1; \
$(MAKE) credo || EXIT=1; \
exit $$EXIT
format:
MIX_ENV=test mix format --check-formatted
test:
MIX_ENV=test mix test
deps:
MIX_ENV=test mix deps.unlock --check-unused
credo:
MIX_ENV=test mix credo --strict

View file

@ -0,0 +1,188 @@
# propmonitor Client Software — Design Plan
**Goal:** Define the `propmonitor` client that runs on RPi-based SDR hardware, authenticates with an API token, fetches its config, and periodically uploads beacon reception measurements.
## Architecture
```
┌──────────────────────┐ POST /api/v1/measurements ┌──────────────────────┐
│ propmonitor client │ ──────────────────────────────► │ Microwaveprop API │
│ (RPi + SDR) │ │ (Elixir / Phoenix) │
│ │ ◄────────────────────────────── │ │
│ │ GET /api/v1/monitors/:token │ │
└──────────────────────┘ (config) └──────────────────────┘
```
## Client responsibilities
1. **Boot registration:** On first run, the client creates a unique hardware ID (e.g. `/etc/machine-id` or a MAC-address-derived UUID) and reports it to the API. The API maps this to the monitor record provisioned by the admin.
2. **Config fetch (`GET /api/v1/monitors/:token`):** The server returns the monitor's active config — which beacon to listen for, what frequency, integration window, and mode.
3. **Measurement loop:** For each integration window:
- Tune SDR to the configured frequency
- Record passband IQ for `config_integration_s` seconds
- Compute noise floor, signal peak/avg, SNR, signal active fraction
- Upload via `POST /api/v1/measurements`
4. **Token-based auth:** The API token (set when the admin provisions the monitor) is the only credential. No user account needed.
## Data flow
```
Monitor token ──► GET /api/v1/monitors/:token ──► { name, beacon, frequency_hz, integration_s, mode }
SDR tunes to frequency_hz
Record for integration_s seconds
Compute SNR, noise floor, etc.
POST /api/v1/measurements ──► { monitor_id, beacon_id, frequency_hz,
snr_avg_db, snr_peak_db, noise_floor_dbfs,
gain_db, integration_s, measured_at, ... }
```
## Endpoints the client uses
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/v1/monitors/:token` | Fetch monitor config (name, beacon, frequency_hz, integration_s, mode, lat, lon) |
| `GET` | `/api/v1/beacons` | List known beacons (for the user to select which to monitor) |
| `POST` | `/api/v1/measurements` | Upload a measurement batch |
## Client config file (`~/.config/propmonitor/config.toml`)
```toml
# Required: token assigned by admin
token = "abc123..."
# Optional overrides (if not set, fetched from API)
frequency_hz = 144_000_000
integration_s = 60
mode = "wspr"
# SDR device
sdr_type = "rtl-sdr"
sdr_gain_db = 40.0
```
On each run the client fetches the latest config from the API, merging file-based overrides on top.
## Key design decisions
1. **Pull config, don't push:** Client polls `GET /api/v1/monitors/:token` on startup and periodically (every 5 minutes) for config changes. No long-lived connection needed.
2. **Token is the identity:** The token binds the client to a specific provisioned monitor record. Multiple clients cannot share a token.
3. **Self-registration via hardware_id:** On first connect, the client submits its `hardware_id` which links it to the correct admin-provisioned monitor record. If no matching hardware_id exists, the server rejects the registration and the admin must provision it first.
4. **Battery-friendly:** The client sleeps between integration windows. For a 60-second integration on a 5-minute cycle, duty cycle is ~20%.
## Language choice
**Python** for initial implementation (fast prototyping, excellent SDR library support via `pyrtlsdr` / `hackrf`), with an eye toward a Rust rewrite for battery-constrained solar-powered deployments.
## Hardware targets
- Raspberry Pi 3/4/5 + RTL-SDR v3 (entry-level, ~$30)
- Raspberry Pi 5 + HackRF One / Airspy HF+ Discovery (mid-range)
- Raspberry Pi 5 + LimeSDR / USRP B200 (advanced, for wideband monitoring)
## Future considerations
- **Webhook config pushes:** Server pushes new config (`PATCH` from admin UI) via a lightweight MQTT or SSE mechanism.
- **OTA firmware updates:** The client checks for new versions on startup via a version endpoint.
- **Solar/battery optimization:** Deeper sleep states, batch uploads, variable integration windows.
- **Offline mode:** Cache the config and buffer measurements if the network is unavailable.
## API validation endpoints needed
### `GET /api/v1/monitors/by-hardware/:hardware_id`
Returns the monitor record for a given hardware_id. Used during registration to map the device to its admin-provisioned record.
**Response:**
```json
{
"id": "uuid",
"name": "Rooftop East",
"token": "abc123...",
"config_frequency_hz": 144000000,
"config_integration_s": 60,
"config_mode": "wspr",
"beacon_id": "uuid"
}
```
### `POST /api/v1/measurements`
Uploads a measurement. The client must set the `Authorization: Bearer <token>` header.
**Request body:**
```json
{
"frequency_hz": 144000000,
"integration_s": 60,
"passband_hz": 200.0,
"gain_db": 40.0,
"noise_floor_dbfs": -85.3,
"signal_peak_dbfs": -72.1,
"signal_avg_dbfs": -75.4,
"snr_peak_db": 13.2,
"snr_avg_db": 9.9,
"signal_active_fraction": 0.85,
"measured_at": "2026-07-21T18:30:00Z",
"propmonitor_version": "0.1.0"
}
```
The `beacon_id` and `monitor_id` are resolved server-side from the token and the beacon matching the frequency + configured beacon.
## Measurement ingestion pipeline
```
POST /api/v1/measurements
BeaconMeasurementsController.create(conn, params)
Look up monitor by token (Authorization header)
Resolve beacon_id from configured beacon (monitor.beacon_id)
(or match by frequency if no explicit beacon configured)
BeaconMeasurements.create_measurement(%{
monitor_id: resolved_monitor_id,
beacon_id: resolved_beacon_id,
frequency_hz: params.frequency_hz,
integration_s: params.integration_s,
passband_hz: params.passband_hz,
gain_db: params.gain_db,
noise_floor_dbfs: params.noise_floor_dbfs,
...
})
Insert into beacon_measurements table
Update monitor.last_seen_at
```
## Implementation order
1. Add `GET /api/v1/monitors/:token` endpoint (returns monitor config)
2. Add `GET /api/v1/monitors/by-hardware/:hardware_id` endpoint (registration lookup)
3. Add `POST /api/v1/measurements` endpoint with token auth
4. Write Python client prototype (`propmonitor/`)
5. Dogfood: deploy to a test RPi with an RTL-SDR
## Related files
- `lib/microwaveprop_web/controllers/` — API v1 controller (where new endpoints go)
- `lib/microwaveprop/beacon_measurements.ex` — context for measurements
- `lib/microwaveprop/beacon_monitors/beacon_monitor.ex` — schema with token, hardware_id, etc.

View file

@ -22,6 +22,7 @@ spec:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
minReadySeconds: 5
selector:
matchLabels:
app: hrrr-point-rs

View file

@ -9,8 +9,8 @@
# for scheduler bin-packing without accidentally changing when HPA
# decides to add a replica.
#
# The corresponding Deployments have their `spec.replicas` removed so
# Flux's periodic reconcile doesn't fight HPA's live scaling.
# The corresponding Deployments omit their spec.replicas so ArgoCD's
# periodic reconcile doesn't fight HPA's live scaling.
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler

View file

@ -13,3 +13,5 @@ resources:
- service.yaml
- metrics-service.yaml
- hpa.yaml
- pdb.yaml
- network-policy.yaml

108
k8s/network-policy.yaml Normal file
View file

@ -0,0 +1,108 @@
# Minimal ingress policy for a home cluster. Default-deny within the
# namespace, then allow:
# - Web traffic to hot pods on :5000 (cloudflared ingress)
# - Prometheus scrape traffic on :5000 (prop metrics) and :9100 (Rust workers)
# - Erlang distribution (EPMD + ephemeral ports) between prop + backfill pods
#
# Egress is unrestricted — pods need to reach Postgres, NFS (10.0.19.103),
# HRRR (skippy + NOAA S3), and IEM.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: prop-allow-web
namespace: prop
spec:
podSelector:
matchLabels:
app: prop
tier: hot
policyTypes:
- Ingress
ingress:
# Web traffic from cloudflared (any source, port :5000).
- from: []
ports:
- port: 5000
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: prop-allow-metrics
namespace: prop
spec:
podSelector:
matchLabels:
app: prop
tier: hot
policyTypes:
- Ingress
ingress:
# Prometheus scrape via apiserver proxy or NodePort.
- from:
- namespaceSelector: {} # any namespace
ports:
- port: 5000
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: grid-rs-allow-metrics
namespace: prop
spec:
podSelector:
matchLabels:
app: prop-grid-rs
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector: {}
ports:
- port: 9100
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: hrrr-point-allow-metrics
namespace: prop
spec:
podSelector:
matchLabels:
app: hrrr-point-rs
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector: {}
ports:
- port: 9100
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: prop-allow-epmd
namespace: prop
spec:
podSelector:
matchLabels:
app: prop
policyTypes:
- Ingress
ingress:
# Erlang distribution within the cluster (libcluster). EPMD listens on
# 4369; actual distribution uses ephemeral ports negotiated via EPMD.
- from:
- podSelector:
matchLabels:
app: prop
ports:
- port: 4369
protocol: TCP
- from:
- podSelector:
matchLabels:
app: prop

11
k8s/pdb.yaml Normal file
View file

@ -0,0 +1,11 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: prop
namespace: prop
spec:
minAvailable: 1
selector:
matchLabels:
app: prop
tier: hot

View file

@ -78,7 +78,7 @@ defmodule Microwaveprop.Accounts do
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
Returns `nil` if the User does not exist.
## Examples
@ -86,11 +86,11 @@ defmodule Microwaveprop.Accounts do
%User{}
get_user!(456)
** (Ecto.NoResultsError)
nil
"""
@spec get_user!(Ecto.UUID.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
@spec get_user!(Ecto.UUID.t()) :: User.t() | nil
def get_user!(id), do: Repo.get(User, id)
## Admin user management
@ -100,6 +100,15 @@ defmodule Microwaveprop.Accounts do
Repo.all(from u in User, order_by: [asc: u.callsign], limit: 100)
end
@doc """
Returns lightweight id/callsign pairs for use in select dropdowns.
Used by admin forms that need to pick a user.
"""
@spec list_users_select() :: [{String.t(), Ecto.UUID.t()}]
def list_users_select do
Repo.all(from u in User, order_by: [asc: u.callsign], select: {u.callsign, u.id})
end
@doc "Updates admin-managed user fields (callsign, name, email, is_admin)."
@spec admin_update_user(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def admin_update_user(%User{} = user, attrs) do

View file

@ -117,6 +117,25 @@ defmodule Microwaveprop.Accounts.UserToken do
}}
end
defp verify_hashed_token_query(token, context, validity, unit) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, context),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^validity, ^unit),
where: token.sent_to == user.email,
select: {user, token}
{:ok, query}
:error ->
:error
end
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
@ -127,24 +146,8 @@ defmodule Microwaveprop.Accounts.UserToken do
of a magic link token is always "login".
"""
@spec verify_magic_link_token_query(String.t()) :: {:ok, Ecto.Query.t()} | :error
def verify_magic_link_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "login"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
where: token.sent_to == user.email,
select: {user, token}
{:ok, query}
:error ->
:error
end
end
def verify_magic_link_token_query(token),
do: verify_hashed_token_query(token, "login", @magic_link_validity_in_minutes, "minute")
@doc """
Checks if the token is valid and returns its underlying lookup query.
@ -153,24 +156,7 @@ defmodule Microwaveprop.Accounts.UserToken do
"confirm" and the token is valid for @confirm_validity_in_days days.
"""
@spec verify_confirm_token_query(String.t()) :: {:ok, Ecto.Query.t()} | :error
def verify_confirm_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "confirm"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^@confirm_validity_in_days, "day"),
where: token.sent_to == user.email,
select: {user, token}
{:ok, query}
:error ->
:error
end
end
def verify_confirm_token_query(token), do: verify_hashed_token_query(token, "confirm", @confirm_validity_in_days, "day")
@doc """
Checks if the token is valid and returns its underlying lookup query.
@ -179,24 +165,8 @@ defmodule Microwaveprop.Accounts.UserToken do
"reset_password" and the token is valid for @reset_password_validity_in_days.
"""
@spec verify_password_reset_token_query(String.t()) :: {:ok, Ecto.Query.t()} | :error
def verify_password_reset_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "reset_password"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^@reset_password_validity_in_days, "day"),
where: token.sent_to == user.email,
select: {user, token}
{:ok, query}
:error ->
:error
end
end
def verify_password_reset_token_query(token),
do: verify_hashed_token_query(token, "reset_password", @reset_password_validity_in_days, "day")
@doc """
Checks if the token is valid and returns its underlying lookup query.

View file

@ -90,7 +90,8 @@ defmodule Microwaveprop.BeaconMeasurements do
from m in BeaconMeasurement,
where: m.beacon_id == ^beacon_id,
order_by: [desc: m.measured_at],
limit: ^limit
limit: ^limit,
preload: [:monitor]
)
end

View file

@ -1,48 +1,148 @@
defmodule Microwaveprop.BeaconMonitors do
@moduledoc """
The BeaconMonitors context: manages the monitor stations a user
has registered. Each monitor has a unique random token the remote
program uses to authenticate its reports.
The BeaconMonitors context: manages the physical SDR-based monitor
hardware assigned to users.
Each monitor has a unique random token the `propmonitor` client uses
to authenticate its measurement uploads.
"""
import Ecto.Query
alias Ecto.Query.CastError
alias Microwaveprop.Accounts.User
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Repo
@token_bytes 32
# ── User-facing queries ──────────────────────────────────────────
@doc """
Returns all monitors for the given user, newest first.
Returns all monitors assigned to the given user, newest first.
Preloads the beacon for display.
"""
@spec list_monitors_for_user(User.t()) :: [BeaconMonitor.t()]
def list_monitors_for_user(%User{id: user_id}) do
Repo.all(
from m in BeaconMonitor,
where: m.user_id == ^user_id,
order_by: [desc: m.inserted_at]
order_by: [desc: m.inserted_at],
preload: [:beacon]
)
end
@doc """
Creates a new monitor for the given user with a freshly generated token.
Returns a single monitor. Preloads beacon and user relations.
"""
@spec get_monitor!(Ecto.UUID.t()) :: BeaconMonitor.t()
def get_monitor!(monitor_id) do
BeaconMonitor |> Repo.get!(monitor_id) |> Repo.preload([:beacon, :user, :assigned_by])
end
@doc """
Returns a single monitor if owned by the given user, nil otherwise.
"""
@spec get_monitor_for_user(Ecto.UUID.t(), User.t()) :: BeaconMonitor.t() | nil
def get_monitor_for_user(monitor_id, %User{id: user_id}) do
Repo.one(
from m in BeaconMonitor,
where: m.id == ^monitor_id and m.user_id == ^user_id,
preload: [:beacon]
)
end
# ── Admin queries ────────────────────────────────────────────────
@doc """
Returns all monitors, newest first, with user and beacon preloaded.
Accepts preload overrides via options.
"""
@spec list_all_monitors(keyword()) :: [BeaconMonitor.t()]
def list_all_monitors(opts \\ []) do
preloads = Keyword.get(opts, :preload, [:user, :beacon, :assigned_by])
from(m in BeaconMonitor, order_by: [desc: m.inserted_at])
|> Repo.all()
|> Repo.preload(preloads)
end
# ── Test / convenience helpers ───────────────────────────────────
@doc """
Creates a monitor and assigns it to the given user. The user acts as
both the assigned owner and the creating admin. Used by tests and
any legacy callers.
"""
@spec create_monitor(User.t(), map()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def create_monitor(%User{} = user, attrs) do
%BeaconMonitor{user_id: user.id, token: generate_token()}
|> BeaconMonitor.changeset(attrs)
def create_monitor(%User{} = user, attrs) when is_list(attrs) or is_map(attrs) do
attrs =
attrs
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> Map.put("user_id", user.id)
create_hardware(user, attrs)
end
# ── Admin provisioning ───────────────────────────────────────────
@doc """
Creates a new hardware monitor with the given attrs. Generates a
unique auth token. Expects `user_id` and `assigned_by_id` to be set
in attrs.
"""
@spec create_hardware(User.t(), map()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def create_hardware(%User{} = admin, attrs) do
attrs = Map.put(attrs, "assigned_by_id", admin.id)
%BeaconMonitor{token: generate_token()}
|> BeaconMonitor.provision_changeset(attrs)
|> Repo.insert()
end
@doc """
Deletes a monitor owned by the given user.
Updates the monitor's configuration (beacon, frequency, mode, etc).
Used by both admins and the assigned user.
"""
@spec update_config(BeaconMonitor.t(), map()) ::
{:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def update_config(%BeaconMonitor{} = monitor, attrs) do
monitor
|> BeaconMonitor.config_changeset(attrs)
|> Repo.update()
end
Returns `{:error, :not_found}` if the monitor does not exist or
belongs to another user.
@doc """
Updates the monitor's hardware provisioning fields. Admin-only.
"""
@spec update_hardware(BeaconMonitor.t(), map()) ::
{:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def update_hardware(%BeaconMonitor{} = monitor, attrs) do
monitor
|> BeaconMonitor.provision_changeset(attrs)
|> Repo.update()
end
@doc """
Reassigns a monitor to a different user. Returns `{:error, :not_found}`
if the target user does not exist.
"""
@spec assign_to_user(BeaconMonitor.t(), User.t(), User.t()) ::
{:ok, BeaconMonitor.t()} | {:error, :not_found | Ecto.Changeset.t()}
def assign_to_user(%BeaconMonitor{} = monitor, %User{id: _} = admin, %User{id: new_user_id}) do
monitor
|> BeaconMonitor.provision_changeset(%{
user_id: new_user_id,
assigned_by_id: admin.id
})
|> Repo.update()
end
@doc """
Deletes a monitor. Works for both admin and the assigned user.
"""
@spec delete_monitor(User.t(), Ecto.UUID.t()) ::
{:ok, BeaconMonitor.t()} | {:error, :not_found} | {:error, Ecto.Changeset.t()}
{:ok, BeaconMonitor.t()} | {:error, :not_found}
def delete_monitor(%User{id: user_id}, monitor_id) do
query =
from m in BeaconMonitor,
@ -53,9 +153,24 @@ defmodule Microwaveprop.BeaconMonitors do
monitor -> Repo.delete(monitor)
end
rescue
Ecto.Query.CastError -> {:error, :not_found}
CastError -> {:error, :not_found}
end
@doc """
Deletes a monitor by its ID without user scoping. Admin-only.
"""
@spec delete_monitor!(Ecto.UUID.t()) :: {:ok, BeaconMonitor.t()} | {:error, :not_found}
def delete_monitor!(monitor_id) do
case Repo.get(BeaconMonitor, monitor_id) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
end
rescue
CastError -> {:error, :not_found}
end
# ── Auth / heartbeat ─────────────────────────────────────────────
@doc """
Looks up a monitor by its token. Returns nil if not found.
"""
@ -81,11 +196,22 @@ defmodule Microwaveprop.BeaconMonitors do
end
@doc """
Returns a blank changeset for rendering the new-monitor form.
Returns a blank changeset for rendering a new-monitor form.
"""
@spec change_monitor(map()) :: Ecto.Changeset.t()
def change_monitor(attrs \\ %{}) do
BeaconMonitor.changeset(%BeaconMonitor{}, attrs)
BeaconMonitor.provision_changeset(%BeaconMonitor{}, attrs)
end
@doc """
Regenerates the monitor's auth token, returning the updated monitor
with its new token.
"""
@spec regenerate_token(BeaconMonitor.t()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def regenerate_token(%BeaconMonitor{} = monitor) do
monitor
|> Ecto.Changeset.change(%{token: generate_token()})
|> Repo.update()
end
defp generate_token do

View file

@ -1,8 +1,11 @@
defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
@moduledoc """
A beacon monitor is a remote station running the monitor program
that reports beacon reception data. Each monitor is owned by a user
and identified by a random token the program uses to authenticate.
A beacon monitor is a physical SDR-based hardware unit assigned to a
user. The unit runs the `propmonitor` client which reports beacon
reception measurements back to Microwaveprop.
Each monitor has a unique random token the client uses to authenticate
its measurement uploads.
"""
use Ecto.Schema
@ -10,15 +13,37 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
import Ecto.Changeset
alias Microwaveprop.Accounts.User
alias Microwaveprop.Beacons.Beacon
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "beacon_monitors" do
# Identity / label
field :name, :string
field :token, :string
field :last_seen_at, :utc_datetime
# Hardware identity
field :hardware_type, :string
field :hardware_id, :string
field :firmware_version, :string
# Antenna / installation
field :antenna_type, :string
field :antenna_gain_dbi, :float
field :lat, :float
field :lon, :float
field :callsign, :string
# Active configuration (admin + assigned user can change)
field :config_frequency_hz, :integer
field :config_integration_s, :integer
field :config_mode, :string
# Relationships
belongs_to :user, User
belongs_to :beacon, Beacon
belongs_to :assigned_by, User
timestamps(type: :utc_datetime)
end
@ -26,8 +51,8 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
@type t :: %__MODULE__{}
@doc """
Changeset for user-controlled fields (name only for now).
Token and user_id are assigned by the context, not cast from user input.
General changeset allows basic fields. Context-level functions
enforce which caller-role can set which fields.
"""
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(monitor, attrs) do
@ -36,4 +61,63 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
end
@doc """
Provisioning changeset used by admins to register new hardware.
Casts all hardware-identity, antenna, and assignment fields.
"""
@spec provision_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def provision_changeset(monitor, attrs) do
monitor
|> cast(attrs, [
:name,
:hardware_type,
:hardware_id,
:firmware_version,
:antenna_type,
:antenna_gain_dbi,
:lat,
:lon,
:callsign,
:user_id,
:assigned_by_id
])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:hardware_type, max: 100)
|> validate_length(:hardware_id, max: 255)
|> validate_length(:firmware_version, max: 50)
|> validate_length(:antenna_type, max: 100)
|> validate_length(:callsign, max: 20)
|> validate_number(:antenna_gain_dbi, greater_than_or_equal_to: -20, less_than_or_equal_to: 60)
|> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90)
|> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:assigned_by_id)
end
@doc """
Configuration changeset used by admins and assigned users to update
what the monitor is listening for.
"""
@spec config_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def config_changeset(monitor, attrs) do
monitor
|> cast(attrs, [
:name,
:beacon_id,
:callsign,
:lat,
:lon,
:config_frequency_hz,
:config_integration_s,
:config_mode
])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:config_mode, max: 50)
|> validate_number(:config_frequency_hz, greater_than: 0)
|> validate_number(:config_integration_s, greater_than_or_equal_to: 5, less_than_or_equal_to: 3600)
|> foreign_key_constraint(:beacon_id)
end
end

View file

@ -145,7 +145,15 @@ defmodule Microwaveprop.Beacons do
end
@doc "Marks a beacon as approved, making it visible in the public list."
@spec approve_beacon(Beacon.t()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
@spec approve_beacon(Beacon.t() | binary()) ::
{:ok, Beacon.t()} | {:error, Ecto.Changeset.t()} | {:error, :not_found}
def approve_beacon(id) when is_binary(id) do
case Repo.get(Beacon, id) do
nil -> {:error, :not_found}
beacon -> approve_beacon(beacon)
end
end
def approve_beacon(%Beacon{} = beacon) do
beacon
|> Ecto.Changeset.change(approved: true)
@ -154,7 +162,15 @@ defmodule Microwaveprop.Beacons do
end
@doc "Deletes a beacon."
@spec delete_beacon(Beacon.t()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
@spec delete_beacon(Beacon.t() | binary()) ::
{:ok, Beacon.t()} | {:error, Ecto.Changeset.t()} | {:error, :not_found}
def delete_beacon(id) when is_binary(id) do
case Repo.get(Beacon, id) do
nil -> {:error, :not_found}
beacon -> delete_beacon(beacon)
end
end
def delete_beacon(%Beacon{} = beacon) do
case Repo.delete(beacon) do
{:ok, beacon} ->

View file

@ -200,26 +200,8 @@ defmodule Microwaveprop.Propagation.PathCompute do
defp fallback_hits(misses, now) do
misses
|> Enum.reverse()
|> Task.async_stream(&fallback_hrrr_point(&1, now),
max_concurrency: 4,
timeout: 5_000,
on_timeout: :kill_task
)
|> Enum.zip(Enum.reverse(misses))
|> Enum.flat_map(fn
{{:ok, nil}, _} ->
[]
{{:ok, point}, _} ->
[point]
{{:exit, reason}, {label, lat, lon}} ->
Logger.error(
"PathCompute HRRR fallback lookup failed: label=#{inspect(label)} lat=#{lat} lon=#{lon} reason=#{inspect(reason)}"
)
[]
end)
|> Enum.map(&fallback_hrrr_point(&1, now))
|> Enum.filter(& &1)
end
@doc "Public for PathLive's `path_forecast_detail` event."

View file

@ -1075,33 +1075,40 @@ defmodule Microwaveprop.Radio do
|> Repo.one()
end
@spec get_contact_edit!(Ecto.UUID.t()) :: ContactEdit.t()
def get_contact_edit!(id) do
@spec get_contact_edit(Ecto.UUID.t()) :: ContactEdit.t() | nil
def get_contact_edit(id) do
ContactEdit
|> preload([:user, :contact, :reviewed_by])
|> Repo.get!(id)
|> Repo.get(id)
end
@spec approve_edit(ContactEdit.t(), User.t(), String.t() | nil) ::
{:ok, ContactEdit.t()} | {:error, any()}
def approve_edit(%ContactEdit{status: :pending} = edit, admin, note) do
Repo.transaction(fn ->
# Mark edit as approved
{:ok, approved} =
edit
|> ContactEdit.review_changeset(%{
status: :approved,
admin_note: note,
reviewed_by_id: admin.id,
reviewed_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Repo.update()
# Check the contact still exists before proceeding — the FK has
# on_delete: :delete_all so if the contact is gone the edit row is
# also gone, making any update to it a StaleEntryError.
case Repo.get(Contact, edit.contact_id) do
nil ->
Repo.rollback(:contact_deleted)
# Apply changes to contact
contact = Repo.get!(Contact, edit.contact_id)
_ = apply_edit_to_contact(contact, edit.proposed_changes)
contact ->
# Mark edit as approved
{:ok, approved} =
edit
|> ContactEdit.review_changeset(%{
status: :approved,
admin_note: note,
reviewed_by_id: admin.id,
reviewed_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Repo.update()
Repo.preload(approved, [:user, :contact, :reviewed_by])
# Apply changes to contact
_ = apply_edit_to_contact(contact, edit.proposed_changes)
Repo.preload(approved, [:user, :contact, :reviewed_by])
end
end)
end

View file

@ -76,6 +76,7 @@ defmodule MicrowavepropWeb.Layouts do
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-44 p-2 shadow-lg"
>
<li><.link navigate="/users">Users</.link></li>
<li><.link navigate="/admin/beacon-monitors">Beacon monitors</.link></li>
<li><.link navigate="/admin/contact-edits">Contact edits</.link></li>
<li><.link navigate="/status">Status</.link></li>
<li><.link href="/admin/oban">Oban</.link></li>

View file

@ -84,22 +84,6 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
json(conn, BeaconMonitorJSON.index(%{monitors: monitors}))
end
@spec create_monitor(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create_monitor(conn, params) do
user = conn.assigns.current_api_user
attrs = Map.take(params, ["name"])
case BeaconMonitors.create_monitor(user, attrs) do
{:ok, monitor} ->
conn
|> put_status(:created)
|> json(BeaconMonitorJSON.show(%{monitor: monitor}))
{:error, changeset} ->
ErrorJSON.send_changeset(conn, changeset)
end
end
@spec delete_monitor(Plug.Conn.t(), map()) :: Plug.Conn.t()
def delete_monitor(conn, %{"id" => id}) do
user = conn.assigns.current_api_user

View file

@ -3,28 +3,6 @@ defmodule MicrowavepropWeb.BeaconMonitorController do
alias Microwaveprop.BeaconMonitors
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, %{"beacon_monitor" => params}) do
user = conn.assigns.current_scope.user
case BeaconMonitors.create_monitor(user, params) do
{:ok, monitor} ->
conn
|> put_flash(:info, "Monitor '#{monitor.name}' created. Copy its token below.")
|> redirect(to: ~p"/users/settings")
{:error, changeset} ->
message =
changeset
|> Ecto.Changeset.traverse_errors(fn {msg, _} -> msg end)
|> Enum.map_join("; ", fn {k, v} -> "#{k}: #{Enum.join(v, ", ")}" end)
conn
|> put_flash(:error, "Could not create monitor (#{message}).")
|> redirect(to: ~p"/users/settings")
end
end
@spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t()
def delete(conn, %{"id" => id}) do
user = conn.assigns.current_scope.user

View file

@ -105,10 +105,7 @@ defmodule MicrowavepropWeb.UserSettingsController do
defp assign_beacon_monitors(conn, _opts) do
user = conn.assigns.current_scope.user
conn
|> assign(:beacon_monitors, BeaconMonitors.list_monitors_for_user(user))
|> assign(:beacon_monitor_changeset, BeaconMonitors.change_monitor())
assign(conn, :beacon_monitors, BeaconMonitors.list_monitors_for_user(user))
end
defp assign_api_tokens(conn, _opts) do

View file

@ -91,49 +91,23 @@
<section id="beacon-monitors" class="space-y-4">
<.header>
Beacon monitors
Assigned beacon monitors
<:subtitle>
Register remote monitor stations. Each monitor gets a unique token the
monitor program uses to authenticate its reports.
Physical monitor hardware assigned to you by an admin. Each unit
reports beacon reception data back to Microwaveprop.
</:subtitle>
</.header>
<div class="alert alert-warning text-sm">
<.icon name="hero-exclamation-triangle" class="size-4" />
<span>Not working yet — monitor client + ingestion pipeline still in development.</span>
</div>
<.form
:let={f}
for={@beacon_monitor_changeset}
as={:beacon_monitor}
action={~p"/users/beacon-monitors"}
id="create_beacon_monitor"
>
<label for={f[:name].id} class="label mb-1">Monitor name</label>
<div class="flex items-center gap-2">
<input
type="text"
name={f[:name].name}
id={f[:name].id}
value={Phoenix.HTML.Form.normalize_value("text", f[:name].value)}
placeholder="e.g. Shack Pi"
class="input flex-1"
required
/>
<.button variant="primary" phx-disable-with="Adding...">Add monitor</.button>
</div>
</.form>
<%= if @beacon_monitors == [] do %>
<p class="text-sm opacity-70">No monitors registered yet.</p>
<p class="text-sm opacity-70">No monitors assigned to you yet.</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Token</th>
<th>Hardware</th>
<th>Monitoring</th>
<th>Last seen</th>
<th class="w-1"></th>
</tr>
@ -142,8 +116,28 @@
<%= for monitor <- @beacon_monitors do %>
<tr>
<td class="font-semibold">{monitor.name}</td>
<td>
<code class="text-xs break-all select-all">{monitor.token}</code>
<td class="text-sm">
<%= if monitor.hardware_type do %>
{monitor.hardware_type}<br />
<span class="opacity-60 text-xs">{monitor.hardware_id}</span>
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</td>
<td class="text-sm">
<%= if monitor.beacon do %>
<.link
navigate={~p"/beacons/#{monitor.beacon.id}"}
class="link link-hover font-mono"
>
{monitor.beacon.callsign}
</.link>
<span class="opacity-60">
{monitor.beacon.frequency_mhz} MHz
</span>
<% else %>
<span class="opacity-50">Not configured</span>
<% end %>
</td>
<td class="text-sm opacity-70">
<%= if monitor.last_seen_at do %>

View file

@ -52,13 +52,22 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
|> assign(:reviewing, nil)
|> assign(:admin_note, "")
|> assign(:pending_count, Radio.pending_edit_count())
|> assign(:flagged_contacts, Radio.list_flagged_contacts())}
|> assign(:flagged_contacts, Radio.list_flagged_contacts())
|> assign(:data_provider, {Radio, :pending_edits_query, []})}
end
@impl true
def handle_event("review", %{"id" => id}, socket) do
edit = Radio.get_contact_edit!(id)
{:noreply, assign(socket, reviewing: edit, admin_note: "")}
case Radio.get_contact_edit(id) do
nil ->
{:noreply,
socket
|> put_flash(:error, "That edit no longer exists — it may have been reviewed by another admin.")
|> push_patch(to: "/" <> (socket.assigns[:current_path] || "admin/contact-edits"))}
edit ->
{:noreply, assign(socket, reviewing: edit, admin_note: "")}
end
end
def handle_event("cancel_review", _params, socket) do
@ -136,6 +145,9 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
defp field_label("band"), do: "Band"
defp field_label("mode"), do: "Mode"
defp field_label("qso_timestamp"), do: "Timestamp"
defp field_label("height1_ft"), do: "Height 1 (ft)"
defp field_label("height2_ft"), do: "Height 2 (ft)"
defp field_label("private"), do: "Private"
defp field_label(other), do: other
defp current_value(contact, "station1"), do: contact.station1
@ -144,6 +156,9 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
defp current_value(contact, "grid2"), do: contact.grid2
defp current_value(contact, "band"), do: if(contact.band, do: Decimal.to_string(contact.band))
defp current_value(contact, "mode"), do: contact.mode
defp current_value(contact, "height1_ft"), do: contact.height1_ft
defp current_value(contact, "height2_ft"), do: contact.height2_ft
defp current_value(contact, "private"), do: if(contact.private, do: "Yes", else: "No")
defp current_value(contact, "qso_timestamp"), do: if(contact.qso_timestamp, do: format_ts(contact.qso_timestamp))

View file

@ -0,0 +1,226 @@
defmodule MicrowavepropWeb.Admin.MonitorLive.Index do
@moduledoc "Admin beacon-monitor list at `/admin/beacon-monitors`."
use MicrowavepropWeb, :live_view
import Ecto.Query
alias Microwaveprop.Accounts.User
alias Microwaveprop.BeaconMonitors
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Repo
@impl true
def mount(_params, _session, socket) do
users = Repo.all(from u in User, order_by: u.callsign)
{:ok,
socket
|> assign(:page_title, "Beacon monitors")
|> assign(:users, users)
|> assign(:form, to_form(BeaconMonitors.change_monitor()))
|> stream(:monitors, [])}
end
@impl true
def handle_params(_params, _url, socket) do
monitors = BeaconMonitors.list_all_monitors()
{:noreply, stream(socket, :monitors, monitors, reset: true)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
<.header>
Beacon monitors
<:subtitle>Provision and manage physical monitor hardware.</:subtitle>
</.header>
<div class="flex justify-end mb-4">
<.button phx-click={JS.push("show-create-form")} variant="primary">
<.icon name="hero-plus" class="size-4" /> New monitor
</.button>
</div>
<div
:if={@form.source.action in [:insert, nil]}
id="create-form"
class="card bg-base-200 shadow-sm mb-6"
>
<div class="card-body">
<h3 class="card-title text-sm">Register new hardware</h3>
<.form for={@form} id="monitor-form" phx-submit="create" phx-change="validate">
<div class="grid grid-cols-2 gap-3">
<.input field={@form[:name]} type="text" label="Name" required />
<.input
field={@form[:user_id]}
type="select"
label="Assign to"
options={user_options(@users)}
/>
<.input
field={@form[:hardware_type]}
type="text"
label="Hardware type"
placeholder="RTL-SDR"
/>
<.input
field={@form[:hardware_id]}
type="text"
label="Hardware ID / Serial"
placeholder="SN-001"
/>
<.input field={@form[:firmware_version]} type="text" label="Firmware version" />
<.input
field={@form[:antenna_type]}
type="text"
label="Antenna type"
placeholder="Dipole"
/>
<.input
field={@form[:antenna_gain_dbi]}
type="number"
step="any"
label="Antenna gain (dBi)"
/>
</div>
<div class="grid grid-cols-2 gap-3 mt-3">
<.input field={@form[:lat]} type="number" step="any" label="Latitude" />
<.input field={@form[:lon]} type="number" step="any" label="Longitude" />
</div>
<footer class="mt-4 flex gap-2">
<.button variant="primary" phx-disable-with="Creating...">Create</.button>
<.button phx-click={JS.push("hide-create-form")}>Cancel</.button>
</footer>
</.form>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Name</th>
<th>Hardware</th>
<th>Assigned to</th>
<th>Monitoring</th>
<th>Last seen</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
<tr :for={{id, monitor} <- @streams.monitors}>
<td class="font-semibold">{monitor.name}</td>
<td class="text-sm">
<%= if monitor.hardware_type do %>
{monitor.hardware_type}
<span :if={monitor.hardware_id} class="opacity-60 text-xs"> ({monitor.hardware_id})</span>
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</td>
<td class="text-sm">
<%= if monitor.user do %>
<.link navigate={~p"/u/#{monitor.user.callsign}"} class="link link-hover font-mono">
{monitor.user.callsign}
</.link>
<% else %>
<span class="opacity-50">Unassigned</span>
<% end %>
</td>
<td class="text-sm">
<%= if monitor.beacon do %>
<.link
navigate={~p"/beacons/#{monitor.beacon.id}"}
class="link link-hover font-mono"
>
{monitor.beacon.callsign}
</.link>
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</td>
<td class="text-sm opacity-70">
<%= if monitor.last_seen_at do %>
{Calendar.strftime(monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</td>
<td>
<div class="flex gap-1">
<.link
navigate={~p"/admin/beacon-monitors/#{monitor.id}"}
class="btn btn-xs btn-ghost"
>
Detail
</.link>
<.link
phx-click={JS.push("delete", value: %{id: monitor.id})}
data-confirm={"Delete monitor '#{monitor.name}'? This cannot be undone."}
class="btn btn-xs btn-ghost text-error"
>
Delete
</.link>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Layouts.app>
"""
end
@impl true
def handle_event("show-create-form", _params, socket) do
form = to_form(BeaconMonitors.change_monitor(), as: :beacon_monitor)
{:noreply, assign(socket, :form, form)}
end
def handle_event("hide-create-form", _params, socket) do
{:noreply, assign(socket, :form, to_form(BeaconMonitors.change_monitor(), action: :ignore))}
end
def handle_event("validate", %{"beacon_monitor" => params}, socket) do
changeset =
%BeaconMonitor{}
|> BeaconMonitor.provision_changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset, as: :beacon_monitor))}
end
def handle_event("create", %{"beacon_monitor" => params}, socket) do
admin = socket.assigns.current_scope.user
case BeaconMonitors.create_hardware(admin, params) do
{:ok, monitor} ->
{:noreply,
socket
|> put_flash(:info, "Monitor '#{monitor.name}' created.")
|> assign(:form, to_form(BeaconMonitors.change_monitor(), action: :ignore))
|> stream(:monitors, [monitor], at: 0)}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset, as: :beacon_monitor))}
end
end
def handle_event("delete", %{"id" => id}, socket) do
case BeaconMonitors.delete_monitor!(id) do
{:ok, monitor} ->
{:noreply,
socket
|> put_flash(:info, "Monitor '#{monitor.name}' deleted.")
|> stream_delete(:monitors, monitor)}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "Monitor not found.")}
end
end
defp user_options(users) do
Enum.map(users, &{&1.callsign, &1.id})
end
end

View file

@ -0,0 +1,343 @@
defmodule MicrowavepropWeb.Admin.MonitorLive.Show do
@moduledoc "Admin beacon-monitor detail at `/admin/beacon-monitors/:id`."
use MicrowavepropWeb, :live_view
import Ecto.Query
alias Microwaveprop.Accounts
alias Microwaveprop.BeaconMeasurements
alias Microwaveprop.BeaconMonitors
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Repo
@impl true
def mount(%{"id" => id}, _session, socket) do
monitor = BeaconMonitors.get_monitor!(id)
beacons = Repo.all(from b in Beacon, order_by: b.callsign)
users = Accounts.list_users_select()
measurements = BeaconMeasurements.list_recent_for_monitor(monitor.id, 20)
{:ok,
socket
|> assign(:page_title, "Monitor: #{monitor.name}")
|> assign(:monitor, monitor)
|> assign(:beacons, beacons)
|> assign(:users, users)
|> assign(:measurements, measurements)
|> assign_form(monitor)
|> assign_reassign_form(monitor)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-5xl">
<.header>
Monitor: {@monitor.name}
<:subtitle>Hardware detail and configuration</:subtitle>
</.header>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-sm">Hardware</h2>
<dl class="text-sm space-y-1">
<div class="flex justify-between">
<dt class="opacity-70">Type</dt>
<dd>{@monitor.hardware_type}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">ID / Serial</dt>
<dd>{@monitor.hardware_id}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Firmware</dt>
<dd>{@monitor.firmware_version}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Antenna</dt>
<dd>{@monitor.antenna_type}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Antenna gain</dt>
<dd>{@monitor.antenna_gain_dbi && "#{@monitor.antenna_gain_dbi} dBi"}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Location</dt>
<dd>
<%= if @monitor.lat && @monitor.lon do %>
{@monitor.lat}, {@monitor.lon}
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</dd>
</div>
</dl>
</div>
</div>
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-sm">Assignment</h2>
<dl class="text-sm space-y-1">
<div class="flex justify-between">
<dt class="opacity-70">Assigned to</dt>
<dd>
<%= if @monitor.user do %>
<.link
navigate={~p"/users/#{@monitor.user.id}/edit"}
class="link link-hover font-mono"
>
{@monitor.user.callsign}
</.link>
<% else %>
<span class="opacity-50">Unassigned</span>
<% end %>
</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Assigned by</dt>
<dd>
<%= if @monitor.assigned_by do %>
<.link
navigate={~p"/users/#{@monitor.assigned_by.id}/edit"}
class="link link-hover font-mono"
>
{@monitor.assigned_by.callsign}
</.link>
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Auth token</dt>
<dd>
<code class="text-xs select-all break-all">{@monitor.token}</code>
<.button
id="regenerate-token-btn"
phx-click={JS.push("regenerate-token")}
data-confirm="Regenerate token? The monitor will need the new token to connect."
class="btn btn-xs btn-ghost ml-1"
>
Regenerate
</.button>
</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Last seen</dt>
<dd class="opacity-70">
<%= if @monitor.last_seen_at do %>
{Calendar.strftime(@monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</dd>
</div>
</dl>
<div class="border-t border-base-300 pt-3 mt-3">
<.form for={@reassign_form} id="reassign-form" phx-submit="reassign">
<div class="flex items-end gap-2">
<div class="flex-1">
<.input
field={@reassign_form[:user_id]}
type="select"
label="Reassign to"
options={@users}
prompt="Select user"
/>
</div>
<.button variant="primary" phx-disable-with="Reassigning...">
Reassign
</.button>
</div>
</.form>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-sm mb-6">
<div class="card-body">
<h2 class="card-title text-sm">Configuration</h2>
<.form for={@form} id="config-form" phx-submit="update-config">
<div class="grid grid-cols-2 gap-3">
<.input field={@form[:name]} type="text" label="Name" required />
<.input
field={@form[:beacon_id]}
type="select"
label="Beacon to monitor"
options={beacon_options(@beacons)}
prompt="Select beacon"
/>
<.input field={@form[:callsign]} type="text" label="Callsign" />
<p class="text-xs opacity-60 -mt-2">For unregistered operators</p>
<.input field={@form[:lat]} type="number" label="Latitude" step="any" />
<.input field={@form[:lon]} type="number" label="Longitude" step="any" />
<.input field={@form[:config_frequency_hz]} type="number" label="Frequency (Hz)" />
<p class="text-xs opacity-60 -mt-2">Override leave blank to use beacon's frequency</p>
<.input field={@form[:config_integration_s]} type="number" label="Integration (s)" />
<p class="text-xs opacity-60 -mt-2">Override leave blank for default (60s)</p>
<.input
field={@form[:config_mode]}
type="text"
label="Mode"
placeholder="e.g. wspr, q65a_30"
/>
</div>
<footer class="mt-4">
<.button variant="primary" phx-disable-with="Saving...">Save configuration</.button>
</footer>
</.form>
</div>
</div>
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-2">
<h2 class="card-title text-sm">Recent measurements</h2>
<.link navigate={~p"/admin/beacon-monitors"} class="btn btn-ghost btn-xs">
&larr; Back to monitors
</.link>
</div>
<%= if @measurements == [] do %>
<p class="text-sm opacity-70">No measurements yet.</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Time</th>
<th>Frequency</th>
<th>SNR avg</th>
<th>SNR peak</th>
<th>Noise floor</th>
<th>Gain</th>
<th>Integration</th>
</tr>
</thead>
<tbody>
<tr :for={m <- @measurements}>
<td class="whitespace-nowrap text-xs">
{Calendar.strftime(m.measured_at, "%H:%M UTC")}
</td>
<td class="text-xs">{format_freq(m.frequency_hz)}</td>
<td class="text-xs">{Float.round(m.snr_avg_db, 1)} dB</td>
<td class="text-xs">{Float.round(m.snr_peak_db, 1)} dB</td>
<td class="text-xs">{Float.round(m.noise_floor_dbfs, 1)} dBFS</td>
<td class="text-xs">{Float.round(m.gain_db, 1)} dB</td>
<td class="text-xs">{m.integration_s}s</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
</Layouts.app>
"""
end
@impl true
def handle_event("update-config", %{"beacon_monitor" => params}, socket) do
monitor = socket.assigns.monitor
case BeaconMonitors.update_config(monitor, params) do
{:ok, updated} ->
{:noreply,
socket
|> assign(:monitor, updated)
|> assign_form(updated)
|> put_flash(:info, "Configuration updated.")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset, as: :beacon_monitor))}
end
end
@impl true
def handle_event("reassign", %{"beacon_monitor" => %{"user_id" => user_id}}, socket)
when is_binary(user_id) and user_id != "" do
admin = socket.assigns.current_scope.user
monitor = socket.assigns.monitor
case Accounts.get_user!(user_id) do
nil ->
{:noreply, put_flash(socket, :error, "User not found.")}
target ->
case BeaconMonitors.assign_to_user(monitor, admin, target) do
{:ok, updated} ->
updated = Repo.preload(updated, [:user, :assigned_by])
{:noreply,
socket
|> assign(:monitor, updated)
|> assign_reassign_form(updated)
|> put_flash(:info, "Monitor reassigned to #{target.callsign}.")}
{:error, changeset} ->
{:noreply,
socket
|> assign_reassign_form(monitor, changeset)
|> put_flash(:error, "Failed to reassign monitor.")}
end
end
end
def handle_event("reassign", _params, socket) do
{:noreply, put_flash(socket, :error, "Please select a user.")}
end
@impl true
def handle_event("regenerate-token", _params, socket) do
monitor = socket.assigns.monitor
case BeaconMonitors.regenerate_token(monitor) do
{:ok, updated} ->
{:noreply,
socket
|> assign(:monitor, updated)
|> put_flash(:info, "Auth token regenerated.")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to regenerate token.")}
end
end
defp assign_form(socket, monitor) do
assign(socket, :form, to_form(BeaconMonitor.config_changeset(monitor, %{}), as: :beacon_monitor))
end
defp assign_reassign_form(socket, monitor, changeset \\ nil) do
form =
if changeset do
to_form(changeset, as: :beacon_monitor)
else
to_form(%{"user_id" => monitor.user_id}, as: :beacon_monitor)
end
assign(socket, :reassign_form, form)
end
defp beacon_options(beacons) do
Enum.map(beacons, &{"#{&1.callsign} @ #{Beacon.format_freq(&1.frequency_mhz)} MHz", &1.id})
end
defp format_freq(hz) when is_integer(hz) and hz >= 1_000_000 do
mhz =
(hz / 1_000_000)
|> Float.round(3)
|> then(fn
n when n == trunc(n) -> trunc(n)
n -> n
end)
"#{mhz} MHz"
end
defp format_freq(hz) when is_integer(hz), do: "#{hz} Hz"
defp format_freq(_), do: ""
end

View file

@ -71,13 +71,19 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
@impl true
def handle_event("delete", %{"id" => id}, socket) do
if admin?(socket.assigns.current_scope) do
beacon = Beacons.get_beacon!(id)
{:ok, _} = Beacons.delete_beacon(beacon)
case Beacons.delete_beacon(id) do
{:ok, deleted} ->
{:noreply,
socket
|> stream_delete(:pending, deleted)
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
{:noreply,
socket
|> stream_delete(:pending, beacon)
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
{:error, :not_found} ->
{:noreply, push_patch(socket, to: path_with_prefix(socket.assigns.current_path))}
{:error, _} ->
{:noreply, socket}
end
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
@ -85,14 +91,20 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
def handle_event("approve", %{"id" => id}, socket) do
if admin?(socket.assigns.current_scope) do
beacon = Beacons.get_beacon!(id)
{:ok, approved} = Beacons.approve_beacon(beacon)
case Beacons.approve_beacon(id) do
{:ok, approved} ->
{:noreply,
socket
|> put_flash(:info, "Approved #{approved.callsign}.")
|> stream_delete(:pending, approved)
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
{:noreply,
socket
|> put_flash(:info, "Approved #{approved.callsign}.")
|> stream_delete(:pending, beacon)
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
{:error, :not_found} ->
{:noreply, push_patch(socket, to: path_with_prefix(socket.assigns.current_path))}
{:error, _} ->
{:noreply, socket}
end
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
@ -106,6 +118,10 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|> patch_beacons_json(beacon)}
end
def handle_info({:updated, %Beacon{approved: true} = beacon}, socket) do
{:noreply, patch_beacons_json(socket, beacon)}
end
def handle_info({:updated, %Beacon{} = beacon}, socket) do
{:noreply,
socket

View file

@ -2,6 +2,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
@moduledoc "Single-beacon detail page with reception-log history at `/beacons/:id`."
use MicrowavepropWeb, :live_view
alias Microwaveprop.BeaconMeasurements
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Beacons.RangeEstimate
@ -152,6 +153,44 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
</div>
</div>
</div>
<div class="card bg-base-200/40 mt-6">
<div class="card-body">
<h2 class="card-title text-sm mb-2">Recent reception reports</h2>
<%= if @measurements == [] do %>
<p class="text-sm opacity-70">No reception reports yet.</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Time</th>
<th>Monitor</th>
<th>Frequency</th>
<th>SNR avg</th>
<th>SNR peak</th>
<th>Noise floor</th>
<th>Gain</th>
</tr>
</thead>
<tbody>
<tr :for={m <- @measurements}>
<td class="whitespace-nowrap text-xs">
{Calendar.strftime(m.measured_at, "%Y-%m-%d %H:%M UTC")}
</td>
<td class="text-xs">{m.monitor.name}</td>
<td class="text-xs">{format_freq(m.frequency_hz)}</td>
<td class="text-xs">{Float.round(m.snr_avg_db, 1)} dB</td>
<td class="text-xs">{Float.round(m.snr_peak_db, 1)} dB</td>
<td class="text-xs">{Float.round(m.noise_floor_dbfs, 1)} dBFS</td>
<td class="text-xs">{Float.round(m.gain_db, 1)} dB</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
</Layouts.app>
"""
end
@ -178,7 +217,8 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|> assign(:coverage_supported, coverage_supported?(beacon))
# Estimate is lazy — computed on the first toggle-on to avoid
# paying the bbox grid cost on every page load.
|> assign(:estimate, nil)}
|> assign(:estimate, nil)
|> assign(:measurements, BeaconMeasurements.list_recent_for_beacon(beacon.id, 10))}
end
end
@ -287,6 +327,15 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
defp format_coord(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 6)
defp format_coord(value), do: to_string(value)
defp format_freq(hz) when is_integer(hz) and hz >= 1_000_000 do
mhz = Float.round(hz / 1_000_000, 3)
"#{mhz} MHz"
end
defp format_freq(hz) when is_integer(hz), do: "#{hz} Hz"
defp format_freq(_), do: ""
defp path_calc_link(%Beacon{} = beacon) do
base = %{
"destination" => precise_grid(beacon),

View file

@ -0,0 +1,238 @@
defmodule MicrowavepropWeb.MonitorLive.Show do
@moduledoc "User-facing beacon-monitor detail at `/beacon-monitors/:id`."
use MicrowavepropWeb, :live_view
import Ecto.Query
alias Microwaveprop.BeaconMeasurements
alias Microwaveprop.BeaconMonitors
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Repo
@impl true
def mount(%{"id" => id}, _session, socket) do
user = socket.assigns.current_scope.user
monitor = BeaconMonitors.get_monitor!(id)
if monitor.user_id == user.id do
beacons = Repo.all(from b in Beacon, where: b.approved == true, order_by: b.callsign)
measurements = BeaconMeasurements.list_recent_for_monitor(monitor.id, 20)
{:ok,
socket
|> assign(:page_title, "My Monitor: #{monitor.name}")
|> assign(:monitor, monitor)
|> assign(:beacons, beacons)
|> assign(:measurements, measurements)
|> assign_form(monitor)}
else
{:ok,
socket
|> put_flash(:error, "You don't have access to that monitor.")
|> push_navigate(to: ~p"/users/settings")}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-4xl">
<.header>
{@monitor.name}
<:subtitle>Your beacon monitor</:subtitle>
</.header>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-sm">Hardware</h2>
<dl class="text-sm space-y-1">
<div class="flex justify-between">
<dt class="opacity-70">Type</dt>
<dd>{@monitor.hardware_type}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Serial / ID</dt>
<dd>{@monitor.hardware_id}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Firmware</dt>
<dd>{@monitor.firmware_version}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Antenna</dt>
<dd>{@monitor.antenna_type}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Antenna gain</dt>
<dd>{@monitor.antenna_gain_dbi && "#{@monitor.antenna_gain_dbi} dBi"}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Location</dt>
<dd>
<%= if @monitor.lat && @monitor.lon do %>
{@monitor.lat}, {@monitor.lon}
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</dd>
</div>
</dl>
</div>
</div>
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-sm">Connection</h2>
<dl class="text-sm space-y-1">
<div class="flex justify-between">
<dt class="opacity-70">Auth token</dt>
<dd><code class="text-xs select-all break-all">{@monitor.token}</code></dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Last seen</dt>
<dd>
<%= if @monitor.last_seen_at do %>
{Calendar.strftime(@monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
<span class="opacity-50">Never</span>
<% end %>
</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Status</dt>
<dd>
<span class={[
"badge badge-sm",
monitor_online?(@monitor) && "badge-success",
!monitor_online?(@monitor) && "badge-warning"
]}>
{if monitor_online?(@monitor), do: "Online", else: "Offline"}
</span>
</dd>
</div>
</dl>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-sm mb-6">
<div class="card-body">
<h2 class="card-title text-sm">Configuration</h2>
<p class="text-xs opacity-70 mb-3">Update what your monitor listens for.</p>
<.form for={@form} id="config-form" phx-submit="update-config">
<div class="grid grid-cols-2 gap-3">
<.input field={@form[:name]} type="text" label="Name" required />
<.input
field={@form[:beacon_id]}
type="select"
label="Beacon to monitor"
options={beacon_options(@beacons)}
prompt="Select beacon"
/>
<.input field={@form[:config_frequency_hz]} type="number" label="Frequency (Hz)" />
<p class="text-xs opacity-60 -mt-2">Override leave blank to use beacon's frequency</p>
<.input field={@form[:config_integration_s]} type="number" label="Integration (s)" />
<p class="text-xs opacity-60 -mt-2">Override leave blank for default (60s)</p>
<.input
field={@form[:config_mode]}
type="text"
label="Mode"
placeholder="e.g. wspr, q65a_30"
/>
</div>
<footer class="mt-4">
<.button variant="primary" phx-disable-with="Saving...">Save</.button>
</footer>
</.form>
</div>
</div>
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-sm mb-2">Recent measurements</h2>
<%= if @measurements == [] do %>
<p class="text-sm opacity-70">
No measurements yet. Once the monitor starts reporting, they'll appear here.
</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Time</th>
<th>Frequency</th>
<th>SNR avg</th>
<th>SNR peak</th>
<th>Noise floor</th>
<th>Gain</th>
<th>Integration</th>
</tr>
</thead>
<tbody>
<tr :for={m <- @measurements}>
<td class="whitespace-nowrap text-xs">
{Calendar.strftime(m.measured_at, "%H:%M UTC")}
</td>
<td class="text-xs">{format_freq(m.frequency_hz)}</td>
<td class="text-xs">{Float.round(m.snr_avg_db, 1)} dB</td>
<td class="text-xs">{Float.round(m.snr_peak_db, 1)} dB</td>
<td class="text-xs">{Float.round(m.noise_floor_dbfs, 1)} dBFS</td>
<td class="text-xs">{Float.round(m.gain_db, 1)} dB</td>
<td class="text-xs">{m.integration_s}s</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
</Layouts.app>
"""
end
@impl true
def handle_event("update-config", %{"beacon_monitor" => params}, socket) do
monitor = socket.assigns.monitor
case BeaconMonitors.update_config(monitor, params) do
{:ok, updated} ->
{:noreply,
socket
|> assign(:monitor, updated)
|> assign_form(updated)
|> put_flash(:info, "Configuration updated.")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset, as: :beacon_monitor))}
end
end
defp monitor_online?(monitor) do
monitor.last_seen_at && DateTime.diff(DateTime.utc_now(), monitor.last_seen_at, :minute) < 30
end
defp assign_form(socket, monitor) do
assign(socket, :form, to_form(BeaconMonitor.config_changeset(monitor, %{}), as: :beacon_monitor))
end
defp beacon_options(beacons) do
Enum.map(beacons, &{"#{&1.callsign} @ #{Beacon.format_freq(&1.frequency_mhz)} MHz", &1.id})
end
defp format_freq(hz) when is_integer(hz) and hz >= 1_000_000 do
mhz =
(hz / 1_000_000)
|> Float.round(3)
|> then(fn
n when n == trunc(n) -> trunc(n)
n -> n
end)
"#{mhz} MHz"
end
defp format_freq(hz) when is_integer(hz), do: "#{hz} Hz"
defp format_freq(_), do: ""
end

View file

@ -30,12 +30,21 @@ defmodule MicrowavepropWeb.RoverPlanningLive do
@spec filters() :: list()
def filters, do: []
@doc false
# LiveTable data_provider: returns a base query so sort/search/pagination
# are applied on top and results come back as full %Mission{} structs
# (the `select_columns` path strips non-field columns like `bands_mhz`).
@spec visible_query_provider() :: Ecto.Query.t()
def visible_query_provider do
from(m in Mission, as: :resource)
end
@impl true
def mount(_params, _session, socket) do
{:ok,
assign(socket,
page_title: "Rover Planning"
)}
socket
|> assign(page_title: "Rover Planning")
|> assign(:data_provider, {__MODULE__, :visible_query_provider, []})}
end
@impl true

View file

@ -1,8 +1,9 @@
defmodule MicrowavepropWeb.UserManagementLive.Edit do
@moduledoc "Admin edit page for a single user (roles, suspension)."
@moduledoc "Admin edit page for a single user (roles, suspension, monitors)."
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts
alias Microwaveprop.BeaconMonitors
@impl true
def render(assigns) do
@ -25,19 +26,89 @@ defmodule MicrowavepropWeb.UserManagementLive.Edit do
<.button navigate={~p"/users"}>Cancel</.button>
</footer>
</.form>
<div class="card bg-base-200 shadow-sm mt-8">
<div class="card-body">
<h2 class="card-title text-sm">Beacon monitors</h2>
<p class="text-xs opacity-70 mb-3">
Monitor hardware assigned to this user.
</p>
<%= if @monitors == [] do %>
<p class="text-sm opacity-70 mb-4">No monitors assigned to this user.</p>
<% else %>
<div class="overflow-x-auto mb-4">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Name</th>
<th>Hardware</th>
<th>Last seen</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
<tr :for={monitor <- @monitors}>
<td>
<.link
navigate={~p"/admin/beacon-monitors/#{monitor.id}"}
class="link link-hover font-semibold"
>
{monitor.name}
</.link>
</td>
<td class="text-sm">
{monitor.hardware_type}
<span :if={monitor.hardware_id} class="opacity-60 text-xs">
({monitor.hardware_id})
</span>
</td>
<td class="text-sm opacity-70">
<%= if monitor.last_seen_at do %>
{Calendar.strftime(monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</td>
<td>
<.link
phx-click={JS.push("unassign-monitor", value: %{id: monitor.id})}
data-confirm={"Remove monitor '#{monitor.name}' from #{@user.callsign}?"}
class="btn btn-xs btn-ghost text-error"
>
Remove
</.link>
</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
user = Accounts.get_user!(id)
case Accounts.get_user!(id) do
nil ->
{:ok,
socket
|> put_flash(:error, "User not found.")
|> push_navigate(to: ~p"/users")}
{:ok,
socket
|> assign(:page_title, "Edit user")
|> assign(:user, user)
|> assign(:form, to_form(Accounts.change_admin_user(user)))}
user ->
monitors = BeaconMonitors.list_monitors_for_user(user)
{:ok,
socket
|> assign(:page_title, "Edit user")
|> assign(:user, user)
|> assign(:monitors, monitors)
|> assign(:form, to_form(Accounts.change_admin_user(user)))}
end
end
@impl true
@ -46,6 +117,7 @@ defmodule MicrowavepropWeb.UserManagementLive.Edit do
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
@impl true
def handle_event("save", %{"user" => params}, socket) do
case Accounts.admin_update_user(socket.assigns.user, params) do
{:ok, _user} ->
@ -58,4 +130,22 @@ defmodule MicrowavepropWeb.UserManagementLive.Edit do
{:noreply, assign(socket, form: to_form(changeset))}
end
end
@impl true
def handle_event("unassign-monitor", %{"id" => monitor_id}, socket) do
user = socket.assigns.user
case BeaconMonitors.delete_monitor!(monitor_id) do
{:ok, monitor} ->
monitors = BeaconMonitors.list_monitors_for_user(user)
{:noreply,
socket
|> assign(:monitors, monitors)
|> put_flash(:info, "Monitor '#{monitor.name}' removed from #{user.callsign}.")}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "Monitor not found.")}
end
end
end

View file

@ -78,13 +78,17 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = Accounts.get_user!(id)
case Accounts.get_user!(id) do
nil ->
{:noreply, put_flash(socket, :error, "User not found.")}
if user.id == socket.assigns.current_scope.user.id do
{:noreply, put_flash(socket, :error, "You cannot delete your own account here.")}
else
{:ok, _} = Accounts.delete_user(user)
{:noreply, stream_delete(socket, :resources, user)}
user ->
if user.id == socket.assigns.current_scope.user.id do
{:noreply, put_flash(socket, :error, "You cannot delete your own account here.")}
else
{:ok, _} = Accounts.delete_user(user)
{:noreply, stream_delete(socket, :resources, user)}
end
end
end

View file

@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.UserProfileLive do
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts
alias Microwaveprop.BeaconMonitors
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Format
@ -23,13 +24,22 @@ defmodule MicrowavepropWeb.UserProfileLive do
beacons = Beacons.list_beacons_for_user(user, viewer)
involving = Radio.list_contacts_involving_callsign(user.callsign, viewer)
monitors =
if viewer && viewer.id == user.id do
BeaconMonitors.list_monitors_for_user(user)
else
[]
end
{:ok,
assign(socket,
page_title: user.callsign,
profile: user,
contacts: contacts,
beacons: beacons,
involving: involving
involving: involving,
beacon_monitors: monitors,
is_own_profile: viewer && viewer.id == user.id
)}
end
end
@ -155,6 +165,71 @@ defmodule MicrowavepropWeb.UserProfileLive do
</div>
</div>
<div :if={@is_own_profile} class="card bg-base-200 shadow-sm mb-6">
<div class="card-body">
<h2 class="card-title">Beacon monitors</h2>
<p class="text-sm opacity-70">
Physical monitor hardware assigned to you.
</p>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Name</th>
<th>Hardware</th>
<th>Monitoring</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
<tr :for={monitor <- @beacon_monitors}>
<td>
<.link
navigate={~p"/beacon-monitors/#{monitor.id}"}
class="link link-hover font-semibold"
>
{monitor.name}
</.link>
</td>
<td class="text-sm">
<%= if monitor.hardware_type do %>
{monitor.hardware_type}<br />
<span class="opacity-60 text-xs">{monitor.hardware_id}</span>
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</td>
<td class="text-sm">
<%= if monitor.beacon do %>
<.link
navigate={~p"/beacons/#{monitor.beacon.id}"}
class="link link-hover font-mono"
>
{monitor.beacon.callsign}
</.link>
<span class="opacity-60"> ({monitor.beacon.frequency_mhz} MHz)</span>
<% else %>
<span class="opacity-50">Not configured</span>
<% end %>
</td>
<td class="text-sm opacity-70">
<%= if monitor.last_seen_at do %>
{Calendar.strftime(monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</td>
</tr>
</tbody>
</table>
</div>
<%= if @beacon_monitors == [] do %>
<p class="text-sm opacity-70">No monitors assigned to you yet.</p>
<% end %>
</div>
</div>
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title">Contacts {@profile.callsign} is in</h2>

View file

@ -228,6 +228,9 @@ defmodule MicrowavepropWeb.Router do
live "/users", UserManagementLive.Index, :index
live "/users/:id/edit", UserManagementLive.Edit, :edit
live "/admin/beacon-monitors", Admin.MonitorLive.Index, :index
live "/admin/beacon-monitors/:id", Admin.MonitorLive.Show, :show
end
end
@ -278,6 +281,9 @@ defmodule MicrowavepropWeb.Router do
# Public per-user profile (contributions: contacts + beacons submitted)
live "/u/:callsign", UserProfileLive
# User-facing beacon-monitor detail (assigned user can view + configure)
live "/beacon-monitors/:id", MonitorLive.Show, :show
end
# Redirect old /qsos routes
@ -318,7 +324,6 @@ defmodule MicrowavepropWeb.Router do
delete "/me/api-tokens/:id", MeController, :revoke_token
get "/me/beacon-monitors", MeController, :list_monitors
post "/me/beacon-monitors", MeController, :create_monitor
delete "/me/beacon-monitors/:id", MeController, :delete_monitor
post "/contacts", ContactController, :create
@ -375,7 +380,6 @@ defmodule MicrowavepropWeb.Router do
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
post "/users/beacon-monitors", BeaconMonitorController, :create
delete "/users/beacon-monitors/:id", BeaconMonitorController, :delete
post "/users/api-tokens", ApiTokenController, :create

View file

@ -165,12 +165,7 @@ defmodule Microwaveprop.MixProject do
&copy_leaflet_images/1,
"phx.digest"
],
precommit: [
"format --check-formatted",
"cmd sh -c 'MIX_ENV=test mix test'",
"deps.unlock --check-unused",
"cmd sh -c 'MIX_ENV=test mix credo --strict'"
]
precommit: ["cmd make precommit"]
]
end

View file

@ -0,0 +1,30 @@
defmodule Microwaveprop.Repo.Migrations.AddHardwareFieldsToBeaconMonitors do
use Ecto.Migration
def change do
alter table(:beacon_monitors) do
# Hardware identity
add :hardware_type, :string
add :hardware_id, :string
add :firmware_version, :string
# Antenna / installation
add :antenna_type, :string
add :antenna_gain_dbi, :float
add :lat, :float
add :lon, :float
# Active configuration (admin+user-settable)
add :beacon_id, references(:beacons, type: :binary_id, on_delete: :nilify_all)
add :config_frequency_hz, :bigint
add :config_integration_s, :integer
add :config_mode, :string
# Assignment audit trail
add :assigned_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
end
create index(:beacon_monitors, [:beacon_id])
create index(:beacon_monitors, [:assigned_by_id])
end
end

View file

@ -0,0 +1,9 @@
defmodule Microwaveprop.Repo.Migrations.AddCallsignToBeaconMonitors do
use Ecto.Migration
def change do
alter table(:beacon_monitors) do
add :callsign, :string, null: true
end
end
end

View file

@ -60,10 +60,8 @@ defmodule Microwaveprop.AccountsTest do
end
describe "get_user!/1" do
test "raises if id is invalid" do
assert_raise Ecto.NoResultsError, fn ->
Accounts.get_user!("11111111-1111-1111-1111-111111111111")
end
test "returns nil if id is invalid" do
assert Accounts.get_user!("11111111-1111-1111-1111-111111111111") == nil
end
test "returns the user with the given id" do
@ -557,4 +555,16 @@ defmodule Microwaveprop.AccountsTest do
refute inspect(changeset.data) =~ "123456"
end
end
describe "list_users_select/0" do
test "returns callsign/id pairs ordered by callsign" do
u1 = user_fixture(callsign: "W5AAA")
u2 = user_fixture(callsign: "K1BBB")
result = Accounts.list_users_select()
assert is_list(result)
assert result |> Enum.find(fn {_cs, id} -> id == u1.id end) |> elem(0) == "W5AAA"
assert result |> Enum.find(fn {_cs, id} -> id == u2.id end) |> elem(0) == "K1BBB"
end
end
end

View file

@ -110,4 +110,17 @@ defmodule Microwaveprop.BeaconMonitorsTest do
refute BeaconMonitors.get_monitor_by_token("not-a-real-token")
end
end
describe "regenerate_token/1" do
test "generates a new token on the monitor" do
user = user_fixture()
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Token Test"})
original_token = monitor.token
{:ok, updated} = BeaconMonitors.regenerate_token(monitor)
assert updated.id == monitor.id
assert updated.token != original_token
assert String.length(updated.token) == 43
end
end
end

View file

@ -34,7 +34,7 @@ defmodule Microwaveprop.Pskr.ClientTest do
pid = start_supervised!({Client, []})
state = :sys.get_state(pid)
assert "6m" in state.bands
assert "6cm" in state.bands
assert "2m" in state.bands
assert "70cm" in state.bands
end

View file

@ -294,6 +294,20 @@ defmodule Microwaveprop.Radio.ContactEditTest do
assert updated.grid1 == "EM15AB"
assert updated.pos1 != contact.pos1
end
test "returns {:error, :contact_deleted} when the contact was removed before approval", %{
contact: contact,
user: user,
admin: admin
} do
{:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
# Delete the contact before approving — the FK cascade (on_delete:
# :delete_all) removes the edit row too.
Repo.delete!(contact)
assert {:error, :contact_deleted} = Radio.approve_edit(edit, admin, nil)
end
end
describe "Radio.reject_edit/3" do

View file

@ -105,25 +105,10 @@ defmodule MicrowavepropWeb.Api.V1.MeControllerTest do
end
end
describe "beacon monitor CRUD" do
test "creates, lists, and deletes a beacon monitor", %{authed: conn} do
created =
conn
|> post(~p"/api/v1/me/beacon-monitors", %{"name" => "Tower"})
|> json_response(201)
id = created["data"]["id"]
describe "beacon monitors" do
test "lists monitors", %{authed: conn} do
list = conn |> get(~p"/api/v1/me/beacon-monitors") |> json_response(200)
assert Enum.any?(list["data"], &(&1["id"] == id))
conn = delete(conn, ~p"/api/v1/me/beacon-monitors/#{id}")
assert response(conn, 204)
end
test "422 when creating with empty name", %{authed: conn} do
conn = post(conn, ~p"/api/v1/me/beacon-monitors", %{"name" => ""})
assert json_response(conn, 422)
assert list["data"] == []
end
test "404 when deleting unknown monitor", %{authed: conn} do

View file

@ -7,39 +7,6 @@ defmodule MicrowavepropWeb.BeaconMonitorControllerTest do
setup :register_and_log_in_user
describe "POST /users/beacon-monitors" do
test "creates a monitor for the current user", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/beacon-monitors", %{
"beacon_monitor" => %{"name" => "Shack Pi"}
})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Shack Pi"
assert [monitor] = BeaconMonitors.list_monitors_for_user(user)
assert monitor.name == "Shack Pi"
assert byte_size(monitor.token) > 0
end
test "shows an error when name is blank", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/beacon-monitors", %{
"beacon_monitor" => %{"name" => ""}
})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "name"
assert BeaconMonitors.list_monitors_for_user(user) == []
end
test "redirects unauthenticated users to login" do
conn = post(build_conn(), ~p"/users/beacon-monitors", %{"beacon_monitor" => %{"name" => "Nope"}})
assert redirected_to(conn) == ~p"/users/log-in"
end
end
describe "DELETE /users/beacon-monitors/:id" do
test "deletes a monitor owned by the current user", %{conn: conn, user: user} do
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Bye"})

View file

@ -207,4 +207,64 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do
assert Repo.get!(ContactEdit, edit.id).status == :pending
end
end
describe "table rendering" do
defp seed_two_edits do
admin = admin_fixture()
submitter = user_fixture()
contact = create_contact(%{station1: "N5XXX"})
{:ok, edit1} =
Microwaveprop.Radio.create_contact_edit(contact, submitter, %{
"station1" => "N5YYY"
})
{:ok, edit2} =
Microwaveprop.Radio.create_contact_edit(contact, submitter, %{
"mode" => "SSB"
})
{admin, contact, submitter, edit1, edit2}
end
test "shows pending edits with station callsigns and submitter callsign in table", %{conn: conn} do
{admin, _contact, submitter, _edit1, _edit2} = seed_two_edits()
{:ok, _lv, html} =
conn
|> log_in_user(admin)
|> live(~p"/admin/contact-edits")
# Table cells should show station callsigns (contact_cell) and
# submitter callsign (user_cell).
assert html =~ "N5XXX"
assert html =~ submitter.callsign
end
test "approved edit is removed from the table", %{conn: conn} do
{admin, _contact, _submitter, edit1, _edit2} = seed_two_edits()
{:ok, lv, html} =
conn
|> log_in_user(admin)
|> live(~p"/admin/contact-edits")
# Both edits are pending and visible.
assert html =~ "2 pending"
assert has_element?(lv, ~s|button[phx-value-id="#{edit1.id}"]|)
# Approve edit1.
lv
|> element(~s|button[phx-click="review"][phx-value-id="#{edit1.id}"]|)
|> render_click()
html = lv |> element(~s|button[phx-click="approve"]|) |> render_click()
# Counter drops to 1.
assert html =~ "1 pending"
# The approved edit's review button is gone from the table.
refute html =~ "phx-value-id=\"#{edit1.id}\""
end
end
end

View file

@ -0,0 +1,73 @@
defmodule MicrowavepropWeb.Admin.MonitorLiveTest do
use MicrowavepropWeb.ConnCase, async: false
import Microwaveprop.AccountsFixtures
import Phoenix.LiveViewTest
alias Microwaveprop.Accounts
alias Microwaveprop.BeaconMonitors
defp admin_and_monitor do
admin = user_fixture()
{:ok, admin} = Accounts.admin_update_user(admin, %{is_admin: true})
target = user_fixture(callsign: "W5MON")
{:ok, monitor} = BeaconMonitors.create_monitor(target, %{"name" => "Test Monitor"})
{admin, target, monitor}
end
describe "index" do
test "lists all monitors", %{conn: conn} do
{admin, _target, monitor} = admin_and_monitor()
conn = log_in_user(conn, admin)
{:ok, _lv, html} = live(conn, ~p"/admin/beacon-monitors")
assert html =~ monitor.name
end
test "redirects non-admin to /", %{conn: conn} do
conn = log_in_user(conn, user_fixture())
assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/admin/beacon-monitors")
end
end
describe "show" do
test "shows monitor details", %{conn: conn} do
{admin, target, monitor} = admin_and_monitor()
conn = log_in_user(conn, admin)
{:ok, _lv, html} = live(conn, ~p"/admin/beacon-monitors/#{monitor.id}")
assert html =~ monitor.name
assert html =~ monitor.token
assert html =~ target.callsign
end
test "reassigns monitor to a different user", %{conn: conn} do
{admin, _target, monitor} = admin_and_monitor()
new_user = user_fixture(callsign: "W5NEW")
conn = log_in_user(conn, admin)
{:ok, lv, _html} = live(conn, ~p"/admin/beacon-monitors/#{monitor.id}")
html =
lv
|> form("#reassign-form", beacon_monitor: %{user_id: new_user.id})
|> render_submit()
assert html =~ "Monitor reassigned to #{new_user.callsign}"
end
test "regenerates the auth token", %{conn: conn} do
{admin, _target, monitor} = admin_and_monitor()
conn = log_in_user(conn, admin)
{:ok, lv, _html} = live(conn, ~p"/admin/beacon-monitors/#{monitor.id}")
html =
lv
|> element("#regenerate-token-btn")
|> render_click()
assert html =~ "Auth token regenerated"
end
end
end

View file

@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.UserManagementLiveTest do
import Phoenix.LiveViewTest
alias Microwaveprop.Accounts
alias Microwaveprop.BeaconMonitors
defp admin_user_fixture do
user = user_fixture()
@ -68,4 +69,32 @@ defmodule MicrowavepropWeb.UserManagementLiveTest do
assert Accounts.get_user!(target.id).is_admin
end
end
describe "Edit monitors" do
test "shows monitors assigned to user", %{conn: conn} do
admin = admin_user_fixture()
target = user_fixture(callsign: "W5MON")
{:ok, _monitor} = BeaconMonitors.create_monitor(target, %{"name" => "Shack Pi"})
conn = log_in_user(conn, admin)
{:ok, _lv, html} = live(conn, ~p"/users/#{target.id}/edit")
assert html =~ "Shack Pi"
assert html =~ "Beacon monitors"
end
test "admin can remove a monitor from a user", %{conn: conn} do
admin = admin_user_fixture()
target = user_fixture(callsign: "W5MON")
{:ok, monitor} = BeaconMonitors.create_monitor(target, %{"name" => "Remove Me"})
conn = log_in_user(conn, admin)
{:ok, lv, html_before} = live(conn, ~p"/users/#{target.id}/edit")
assert html_before =~ "Remove Me"
render_click(lv, "unassign-monitor", %{"id" => monitor.id})
monitors = BeaconMonitors.list_monitors_for_user(target)
assert monitors == []
end
end
end

View file

@ -7,6 +7,7 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do
import Phoenix.LiveViewTest
alias Microwaveprop.Accounts.User
alias Microwaveprop.BeaconMonitors
alias Microwaveprop.Beacons
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
@ -217,5 +218,56 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do
assert html =~ ~r/font-mono[^>]*>\s*W\s*</s
end
test "anonymous visitors do not see beacon monitors section", %{conn: conn} do
user = user_fixture(%{callsign: "W5ISP"})
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
refute html =~ "Beacon monitors"
refute html =~ "No monitors registered yet"
end
test "another logged-in user does not see beacon monitors section", %{conn: conn} do
owner = user_fixture(%{callsign: "W5ISP"})
other = user_fixture(%{callsign: "W5OTH"})
conn = log_in_user(conn, other)
{:ok, _lv, html} = live(conn, ~p"/u/#{owner.callsign}")
refute html =~ "Beacon monitors"
refute html =~ "No monitors registered yet"
end
test "owners see empty-state when they have no beacon monitors", %{conn: conn} do
user = user_fixture(%{callsign: "W5ISP"})
conn = log_in_user(conn, user)
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
assert html =~ "Beacon monitors"
assert html =~ "No monitors assigned to you yet"
end
test "owners see their assigned beacon monitors", %{conn: conn} do
user = user_fixture(%{callsign: "W5ISP"})
{:ok, monitor} =
BeaconMonitors.create_hardware(user, %{
"name" => "Shack Pi",
"hardware_type" => "RTL-SDR",
"hardware_id" => "SN-001",
"user_id" => user.id
})
conn = log_in_user(conn, user)
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
assert html =~ "Beacon monitors"
assert html =~ "Shack Pi"
assert html =~ "RTL-SDR"
assert html =~ "SN-001"
assert html =~ "never"
end
end
end