Compare commits
10 commits
e186c4c4ef
...
51678e0fb9
| Author | SHA1 | Date | |
|---|---|---|---|
| 51678e0fb9 | |||
| ca842e3add | |||
| f35da3a935 | |||
|
|
49ade78766 | ||
| fb49eb016d | |||
| 739984d3bc | |||
|
|
c193f35a0c | ||
|
|
5c6cef2227 | ||
|
|
aa6f2ea647 | ||
|
|
343c8ea339 |
44 changed files with 2081 additions and 295 deletions
21
Makefile
Normal file
21
Makefile
Normal 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
|
||||
188
docs/plans/2026-07-21-propmonitor-client-software.md
Normal file
188
docs/plans/2026-07-21-propmonitor-client-software.md
Normal 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.
|
||||
|
|
@ -22,6 +22,7 @@ spec:
|
|||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
minReadySeconds: 5
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hrrr-point-rs
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,3 +13,5 @@ resources:
|
|||
- service.yaml
|
||||
- metrics-service.yaml
|
||||
- hpa.yaml
|
||||
- pdb.yaml
|
||||
- network-policy.yaml
|
||||
|
|
|
|||
108
k8s/network-policy.yaml
Normal file
108
k8s/network-policy.yaml
Normal 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
11
k8s/pdb.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: prop
|
||||
namespace: prop
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: prop
|
||||
tier: hot
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">—</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 %>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
226
lib/microwaveprop_web/live/admin/monitor_live/index.ex
Normal file
226
lib/microwaveprop_web/live/admin/monitor_live/index.ex
Normal 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">—</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">—</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
|
||||
343
lib/microwaveprop_web/live/admin/monitor_live/show.ex
Normal file
343
lib/microwaveprop_web/live/admin/monitor_live/show.ex
Normal 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">—</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">—</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">
|
||||
← 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
238
lib/microwaveprop_web/live/monitor_live/show.ex
Normal file
238
lib/microwaveprop_web/live/monitor_live/show.ex
Normal 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">—</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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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">—</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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
mix.exs
7
mix.exs
|
|
@ -165,12 +165,7 @@ defmodule Microwaveprop.MixProject do
|
|||
©_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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
73
test/microwaveprop_web/live/admin/monitor_live_test.exs
Normal file
73
test/microwaveprop_web/live/admin/monitor_live_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue