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
|
||||
@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])
|
||||
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,11 @@ 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
|
||||
|
||||
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,36 @@ 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
|
||||
|
||||
# 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 +50,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 +60,52 @@ 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,
|
||||
: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
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -169,48 +169,56 @@ defmodule MicrowavepropWeb.UserProfileLive do
|
|||
<div class="card-body">
|
||||
<h2 class="card-title">Beacon monitors</h2>
|
||||
<p class="text-sm opacity-70">
|
||||
Remote monitor stations that report beacon reception data.
|
||||
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 class="font-semibold">{monitor.name}</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 %>
|
||||
<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">
|
||||
<table class="table table-zebra table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Token</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={monitor <- @beacon_monitors}>
|
||||
<td class="font-semibold">{monitor.name}</td>
|
||||
<td>
|
||||
<code class="text-xs select-all">…{String.slice(monitor.token, -8, 8)}</code>
|
||||
</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>
|
||||
<div class="flex justify-end">
|
||||
<.link navigate={~p"/users/settings#beacon-monitors"} class="btn btn-primary btn-sm">
|
||||
<.icon name="hero-plus" class="size-4" /> Register another monitor
|
||||
</.link>
|
||||
</div>
|
||||
<p class="text-sm opacity-70">No monitors assigned to you yet.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -318,7 +318,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 +374,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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"})
|
||||
|
|
|
|||
|
|
@ -246,24 +246,28 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do
|
|||
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
|
||||
|
||||
assert html =~ "Beacon monitors"
|
||||
assert html =~ "No monitors registered yet"
|
||||
assert html =~ "Register a monitor"
|
||||
assert html =~ "No monitors assigned to you yet"
|
||||
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"})
|
||||
|
||||
{: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)
|
||||
{: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"
|
||||
assert html =~ "Register another monitor"
|
||||
# Token is shown truncated (last 8 chars)
|
||||
assert html =~ String.slice(monitor.token, -8, 8)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue