feat(monitors): schema migration + remove user self-service creation
- Add hardware/config fields migration to beacon_monitors table - Update BeaconMonitor schema with provision/config changesets - Add context functions: create_hardware, update_config, list_all_monitors - Remove user-facing monitor creation (browser POST + API POST) - Update settings page: show assigned monitors table with hardware info - Update profile page: show assigned monitors, remove register links - Fix all tests to match new API
This commit is contained in:
parent
739984d3bc
commit
fb49eb016d
12 changed files with 328 additions and 196 deletions
|
|
@ -1,48 +1,148 @@
|
||||||
defmodule Microwaveprop.BeaconMonitors do
|
defmodule Microwaveprop.BeaconMonitors do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
The BeaconMonitors context: manages the monitor stations a user
|
The BeaconMonitors context: manages the physical SDR-based monitor
|
||||||
has registered. Each monitor has a unique random token the remote
|
hardware assigned to users.
|
||||||
program uses to authenticate its reports.
|
|
||||||
|
Each monitor has a unique random token the `propmonitor` client uses
|
||||||
|
to authenticate its measurement uploads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Ecto.Query.CastError
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.BeaconMonitors.BeaconMonitor
|
alias Microwaveprop.BeaconMonitors.BeaconMonitor
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
|
|
||||||
@token_bytes 32
|
@token_bytes 32
|
||||||
|
|
||||||
|
# ── User-facing queries ──────────────────────────────────────────
|
||||||
|
|
||||||
@doc """
|
@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()]
|
@spec list_monitors_for_user(User.t()) :: [BeaconMonitor.t()]
|
||||||
def list_monitors_for_user(%User{id: user_id}) do
|
def list_monitors_for_user(%User{id: user_id}) do
|
||||||
Repo.all(
|
Repo.all(
|
||||||
from m in BeaconMonitor,
|
from m in BeaconMonitor,
|
||||||
where: m.user_id == ^user_id,
|
where: m.user_id == ^user_id,
|
||||||
order_by: [desc: m.inserted_at]
|
order_by: [desc: m.inserted_at],
|
||||||
|
preload: [:beacon]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@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])
|
||||||
|
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()}
|
@spec create_monitor(User.t(), map()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
|
||||||
def create_monitor(%User{} = user, attrs) do
|
def create_monitor(%User{} = user, attrs) when is_list(attrs) or is_map(attrs) do
|
||||||
%BeaconMonitor{user_id: user.id, token: generate_token()}
|
attrs =
|
||||||
|> BeaconMonitor.changeset(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()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@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
|
@doc """
|
||||||
belongs to another user.
|
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()) ::
|
@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
|
def delete_monitor(%User{id: user_id}, monitor_id) do
|
||||||
query =
|
query =
|
||||||
from m in BeaconMonitor,
|
from m in BeaconMonitor,
|
||||||
|
|
@ -53,9 +153,24 @@ defmodule Microwaveprop.BeaconMonitors do
|
||||||
monitor -> Repo.delete(monitor)
|
monitor -> Repo.delete(monitor)
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
Ecto.Query.CastError -> {:error, :not_found}
|
CastError -> {:error, :not_found}
|
||||||
end
|
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 """
|
@doc """
|
||||||
Looks up a monitor by its token. Returns nil if not found.
|
Looks up a monitor by its token. Returns nil if not found.
|
||||||
"""
|
"""
|
||||||
|
|
@ -81,11 +196,11 @@ defmodule Microwaveprop.BeaconMonitors do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@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()
|
@spec change_monitor(map()) :: Ecto.Changeset.t()
|
||||||
def change_monitor(attrs \\ %{}) do
|
def change_monitor(attrs \\ %{}) do
|
||||||
BeaconMonitor.changeset(%BeaconMonitor{}, attrs)
|
BeaconMonitor.provision_changeset(%BeaconMonitor{}, attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_token do
|
defp generate_token do
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
|
defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
A beacon monitor is a remote station running the monitor program
|
A beacon monitor is a physical SDR-based hardware unit assigned to a
|
||||||
that reports beacon reception data. Each monitor is owned by a user
|
user. The unit runs the `propmonitor` client which reports beacon
|
||||||
and identified by a random token the program uses to authenticate.
|
reception measurements back to Microwaveprop.
|
||||||
|
|
||||||
|
Each monitor has a unique random token the client uses to authenticate
|
||||||
|
its measurement uploads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
|
|
@ -10,15 +13,36 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
|
alias Microwaveprop.Beacons.Beacon
|
||||||
|
|
||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
@foreign_key_type :binary_id
|
@foreign_key_type :binary_id
|
||||||
schema "beacon_monitors" do
|
schema "beacon_monitors" do
|
||||||
|
# Identity / label
|
||||||
field :name, :string
|
field :name, :string
|
||||||
field :token, :string
|
field :token, :string
|
||||||
field :last_seen_at, :utc_datetime
|
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
|
||||||
|
|
||||||
|
# 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 :user, User
|
||||||
|
belongs_to :beacon, Beacon
|
||||||
|
belongs_to :assigned_by, User
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
|
|
@ -26,8 +50,8 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
|
||||||
@type t :: %__MODULE__{}
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Changeset for user-controlled fields (name only for now).
|
General changeset — allows basic fields. Context-level functions
|
||||||
Token and user_id are assigned by the context, not cast from user input.
|
enforce which caller-role can set which fields.
|
||||||
"""
|
"""
|
||||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(monitor, attrs) do
|
def changeset(monitor, attrs) do
|
||||||
|
|
@ -36,4 +60,52 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
|
||||||
|> validate_required([:name])
|
|> validate_required([:name])
|
||||||
|> validate_length(:name, min: 1, max: 100)
|
|> validate_length(:name, min: 1, max: 100)
|
||||||
end
|
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,
|
||||||
|
: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_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, :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
|
end
|
||||||
|
|
|
||||||
|
|
@ -84,22 +84,6 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
|
||||||
json(conn, BeaconMonitorJSON.index(%{monitors: monitors}))
|
json(conn, BeaconMonitorJSON.index(%{monitors: monitors}))
|
||||||
end
|
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()
|
@spec delete_monitor(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||||
def delete_monitor(conn, %{"id" => id}) do
|
def delete_monitor(conn, %{"id" => id}) do
|
||||||
user = conn.assigns.current_api_user
|
user = conn.assigns.current_api_user
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,6 @@ defmodule MicrowavepropWeb.BeaconMonitorController do
|
||||||
|
|
||||||
alias Microwaveprop.BeaconMonitors
|
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()
|
@spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||||
def delete(conn, %{"id" => id}) do
|
def delete(conn, %{"id" => id}) do
|
||||||
user = conn.assigns.current_scope.user
|
user = conn.assigns.current_scope.user
|
||||||
|
|
|
||||||
|
|
@ -105,10 +105,7 @@ defmodule MicrowavepropWeb.UserSettingsController do
|
||||||
|
|
||||||
defp assign_beacon_monitors(conn, _opts) do
|
defp assign_beacon_monitors(conn, _opts) do
|
||||||
user = conn.assigns.current_scope.user
|
user = conn.assigns.current_scope.user
|
||||||
|
assign(conn, :beacon_monitors, BeaconMonitors.list_monitors_for_user(user))
|
||||||
conn
|
|
||||||
|> assign(:beacon_monitors, BeaconMonitors.list_monitors_for_user(user))
|
|
||||||
|> assign(:beacon_monitor_changeset, BeaconMonitors.change_monitor())
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp assign_api_tokens(conn, _opts) do
|
defp assign_api_tokens(conn, _opts) do
|
||||||
|
|
|
||||||
|
|
@ -91,49 +91,23 @@
|
||||||
|
|
||||||
<section id="beacon-monitors" class="space-y-4">
|
<section id="beacon-monitors" class="space-y-4">
|
||||||
<.header>
|
<.header>
|
||||||
Beacon monitors
|
Assigned beacon monitors
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
Register remote monitor stations. Each monitor gets a unique token the
|
Physical monitor hardware assigned to you by an admin. Each unit
|
||||||
monitor program uses to authenticate its reports.
|
reports beacon reception data back to Microwaveprop.
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
</.header>
|
</.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 %>
|
<%= 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 %>
|
<% else %>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Token</th>
|
<th>Hardware</th>
|
||||||
|
<th>Monitoring</th>
|
||||||
<th>Last seen</th>
|
<th>Last seen</th>
|
||||||
<th class="w-1"></th>
|
<th class="w-1"></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -142,8 +116,28 @@
|
||||||
<%= for monitor <- @beacon_monitors do %>
|
<%= for monitor <- @beacon_monitors do %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="font-semibold">{monitor.name}</td>
|
<td class="font-semibold">{monitor.name}</td>
|
||||||
<td>
|
<td class="text-sm">
|
||||||
<code class="text-xs break-all select-all">{monitor.token}</code>
|
<%= 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>
|
||||||
<td class="text-sm opacity-70">
|
<td class="text-sm opacity-70">
|
||||||
<%= if monitor.last_seen_at do %>
|
<%= if monitor.last_seen_at do %>
|
||||||
|
|
|
||||||
|
|
@ -169,31 +169,42 @@ defmodule MicrowavepropWeb.UserProfileLive do
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title">Beacon monitors</h2>
|
<h2 class="card-title">Beacon monitors</h2>
|
||||||
<p class="text-sm opacity-70">
|
<p class="text-sm opacity-70">
|
||||||
Remote monitor stations that report beacon reception data.
|
Physical monitor hardware assigned to you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<%= if @beacon_monitors == [] do %>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<p class="text-sm opacity-70">No monitors registered yet.</p>
|
|
||||||
<.link navigate={~p"/users/settings#beacon-monitors"} class="btn btn-primary btn-sm">
|
|
||||||
<.icon name="hero-plus" class="size-4" /> Register a monitor
|
|
||||||
</.link>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-zebra table-sm">
|
<table class="table table-zebra table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Token</th>
|
<th>Hardware</th>
|
||||||
|
<th>Monitoring</th>
|
||||||
<th>Last seen</th>
|
<th>Last seen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr :for={monitor <- @beacon_monitors}>
|
<tr :for={monitor <- @beacon_monitors}>
|
||||||
<td class="font-semibold">{monitor.name}</td>
|
<td class="font-semibold">{monitor.name}</td>
|
||||||
<td>
|
<td class="text-sm">
|
||||||
<code class="text-xs select-all">…{String.slice(monitor.token, -8, 8)}</code>
|
<%= 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>
|
||||||
<td class="text-sm opacity-70">
|
<td class="text-sm opacity-70">
|
||||||
<%= if monitor.last_seen_at do %>
|
<%= if monitor.last_seen_at do %>
|
||||||
|
|
@ -206,11 +217,8 @@ defmodule MicrowavepropWeb.UserProfileLive do
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<%= if @beacon_monitors == [] do %>
|
||||||
<.link navigate={~p"/users/settings#beacon-monitors"} class="btn btn-primary btn-sm">
|
<p class="text-sm opacity-70">No monitors assigned to you yet.</p>
|
||||||
<.icon name="hero-plus" class="size-4" /> Register another monitor
|
|
||||||
</.link>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -318,7 +318,6 @@ defmodule MicrowavepropWeb.Router do
|
||||||
delete "/me/api-tokens/:id", MeController, :revoke_token
|
delete "/me/api-tokens/:id", MeController, :revoke_token
|
||||||
|
|
||||||
get "/me/beacon-monitors", MeController, :list_monitors
|
get "/me/beacon-monitors", MeController, :list_monitors
|
||||||
post "/me/beacon-monitors", MeController, :create_monitor
|
|
||||||
delete "/me/beacon-monitors/:id", MeController, :delete_monitor
|
delete "/me/beacon-monitors/:id", MeController, :delete_monitor
|
||||||
|
|
||||||
post "/contacts", ContactController, :create
|
post "/contacts", ContactController, :create
|
||||||
|
|
@ -375,7 +374,6 @@ defmodule MicrowavepropWeb.Router do
|
||||||
put "/users/settings", UserSettingsController, :update
|
put "/users/settings", UserSettingsController, :update
|
||||||
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
|
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
|
||||||
|
|
||||||
post "/users/beacon-monitors", BeaconMonitorController, :create
|
|
||||||
delete "/users/beacon-monitors/:id", BeaconMonitorController, :delete
|
delete "/users/beacon-monitors/:id", BeaconMonitorController, :delete
|
||||||
|
|
||||||
post "/users/api-tokens", ApiTokenController, :create
|
post "/users/api-tokens", ApiTokenController, :create
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -105,25 +105,10 @@ defmodule MicrowavepropWeb.Api.V1.MeControllerTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "beacon monitor CRUD" do
|
describe "beacon monitors" do
|
||||||
test "creates, lists, and deletes a beacon monitor", %{authed: conn} do
|
test "lists monitors", %{authed: conn} do
|
||||||
created =
|
|
||||||
conn
|
|
||||||
|> post(~p"/api/v1/me/beacon-monitors", %{"name" => "Tower"})
|
|
||||||
|> json_response(201)
|
|
||||||
|
|
||||||
id = created["data"]["id"]
|
|
||||||
|
|
||||||
list = conn |> get(~p"/api/v1/me/beacon-monitors") |> json_response(200)
|
list = conn |> get(~p"/api/v1/me/beacon-monitors") |> json_response(200)
|
||||||
assert Enum.any?(list["data"], &(&1["id"] == id))
|
assert list["data"] == []
|
||||||
|
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "404 when deleting unknown monitor", %{authed: conn} do
|
test "404 when deleting unknown monitor", %{authed: conn} do
|
||||||
|
|
|
||||||
|
|
@ -7,39 +7,6 @@ defmodule MicrowavepropWeb.BeaconMonitorControllerTest do
|
||||||
|
|
||||||
setup :register_and_log_in_user
|
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
|
describe "DELETE /users/beacon-monitors/:id" do
|
||||||
test "deletes a monitor owned by the current user", %{conn: conn, user: user} do
|
test "deletes a monitor owned by the current user", %{conn: conn, user: user} do
|
||||||
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Bye"})
|
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Bye"})
|
||||||
|
|
|
||||||
|
|
@ -246,24 +246,28 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do
|
||||||
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
|
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
|
||||||
|
|
||||||
assert html =~ "Beacon monitors"
|
assert html =~ "Beacon monitors"
|
||||||
assert html =~ "No monitors registered yet"
|
assert html =~ "No monitors assigned to you yet"
|
||||||
assert html =~ "Register a monitor"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "owners see their registered beacon monitors", %{conn: conn} do
|
test "owners see their assigned beacon monitors", %{conn: conn} do
|
||||||
user = user_fixture(%{callsign: "W5ISP"})
|
user = user_fixture(%{callsign: "W5ISP"})
|
||||||
|
|
||||||
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{name: "Shack Pi"})
|
{: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)
|
conn = log_in_user(conn, user)
|
||||||
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
|
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
|
||||||
|
|
||||||
assert html =~ "Beacon monitors"
|
assert html =~ "Beacon monitors"
|
||||||
assert html =~ "Shack Pi"
|
assert html =~ "Shack Pi"
|
||||||
|
assert html =~ "RTL-SDR"
|
||||||
|
assert html =~ "SN-001"
|
||||||
assert html =~ "never"
|
assert html =~ "never"
|
||||||
assert html =~ "Register another monitor"
|
|
||||||
# Token is shown truncated (last 8 chars)
|
|
||||||
assert html =~ String.slice(monitor.token, -8, 8)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue