Revert "feat: add beacon monitors with user and admin management"

This reverts commit 88fae5d752.
This commit is contained in:
Graham McInitre 2026-07-22 08:25:04 -05:00
parent 88fae5d752
commit bff35368b0
18 changed files with 4 additions and 2257 deletions

View file

@ -1,145 +0,0 @@
defmodule Towerops.Beacons do
@moduledoc """
The Beacons context for managing beacon monitors.
Beacon monitors continuously check network endpoints (HTTP, TCP, DNS, SSL, ICMP)
from distributed locations and track their availability status over time.
"""
alias Towerops.Beacons.BeaconMonitor
alias Towerops.Beacons.BeaconMonitorQuery
alias Towerops.Repo
# ── Queries ────────────────────────────────────────
@doc """
Lists all beacon monitors for an organization, ordered by name.
"""
@spec list_organization_beacons(Ecto.UUID.t()) :: [BeaconMonitor.t()]
def list_organization_beacons(organization_id) do
BeaconMonitorQuery.base()
|> BeaconMonitorQuery.for_organization(organization_id)
|> BeaconMonitorQuery.order_by_name()
|> Repo.all()
end
@doc """
Lists beacon monitors for an organization that were created by a specific user.
"""
@spec list_user_beacons(Ecto.UUID.t(), Ecto.UUID.t()) :: [BeaconMonitor.t()]
def list_user_beacons(organization_id, user_id) do
BeaconMonitorQuery.base()
|> BeaconMonitorQuery.for_organization(organization_id)
|> BeaconMonitorQuery.for_user(user_id)
|> BeaconMonitorQuery.order_by_name()
|> Repo.all()
end
@doc """
Lists all beacon monitors across all organizations, with associations preloaded.
"""
@spec list_all_beacons() :: [BeaconMonitor.t()]
def list_all_beacons do
BeaconMonitorQuery.base()
|> BeaconMonitorQuery.preload_all()
|> BeaconMonitorQuery.order_by_name()
|> Repo.all()
end
@doc """
Gets a single beacon monitor by ID. Returns `nil` if not found.
"""
@spec get_beacon(Ecto.UUID.t()) :: BeaconMonitor.t() | nil
def get_beacon(id), do: Repo.get(BeaconMonitor, id)
@doc """
Gets a single beacon monitor by ID, raising `Ecto.NoResultsError` if not found.
"""
@spec get_beacon!(Ecto.UUID.t()) :: BeaconMonitor.t()
def get_beacon!(id), do: Repo.get!(BeaconMonitor, id)
@doc """
Gets a beacon monitor by ID scoped to an organization.
Returns `nil` if the beacon doesn't exist or belongs to a different organization.
"""
@spec get_beacon_for_org(Ecto.UUID.t(), Ecto.UUID.t()) :: BeaconMonitor.t() | nil
def get_beacon_for_org(id, organization_id) do
Repo.get_by(BeaconMonitor, id: id, organization_id: organization_id)
end
@doc """
Gets a beacon monitor by ID scoped to a user.
Returns `nil` if the beacon doesn't exist or belongs to a different user.
"""
@spec get_beacon_for_user(Ecto.UUID.t(), Ecto.UUID.t()) :: BeaconMonitor.t() | nil
def get_beacon_for_user(id, user_id) do
Repo.get_by(BeaconMonitor, id: id, user_id: user_id)
end
# ── CRUD ──────────────────────────────────────────
@doc """
Creates a new beacon monitor with the given attributes.
"""
@spec create_beacon(map()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def create_beacon(attrs) do
%BeaconMonitor{}
|> BeaconMonitor.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates an existing beacon monitor with the given attributes.
"""
@spec update_beacon(BeaconMonitor.t(), map()) ::
{:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def update_beacon(%BeaconMonitor{} = beacon, attrs) do
beacon
|> BeaconMonitor.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a beacon monitor.
"""
@spec delete_beacon(BeaconMonitor.t()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def delete_beacon(%BeaconMonitor{} = beacon), do: Repo.delete(beacon)
@doc """
Toggles the enabled state of a beacon monitor.
"""
@spec toggle_beacon(BeaconMonitor.t()) ::
{:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def toggle_beacon(%BeaconMonitor{enabled: false} = beacon), do: update_beacon(beacon, %{enabled: true})
def toggle_beacon(%BeaconMonitor{enabled: true} = beacon), do: update_beacon(beacon, %{enabled: false})
# ── Monitoring Updates ───────────────────────────
@doc """
Records the result of a beacon check execution.
Updates the beacon's last status, response time, check count, and failure count.
"""
@spec record_check_result(BeaconMonitor.t(), String.t(), number()) ::
{:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def record_check_result(%BeaconMonitor{} = beacon, status, response_time_ms) do
last_response_time_ms = response_time_ms / 1
checks_count = beacon.checks_count + 1
failures_count = increment_failures(status, beacon.failures_count)
update_beacon(beacon, %{
last_status: status,
last_response_time_ms: last_response_time_ms,
last_checked_at: DateTime.utc_now(),
checks_count: checks_count,
failures_count: failures_count
})
end
# A "up" status does not increment the failure count
defp increment_failures("up", current), do: current
# Any other status increments the failure count
defp increment_failures(_status, current), do: current + 1
end

View file

@ -1,108 +0,0 @@
defmodule Towerops.Beacons.BeaconMonitor do
@moduledoc """
Represents a beacon monitor configuration.
A beacon monitor continuously checks a network endpoint (HTTP, TCP, DNS, SSL, ICMP)
from distributed locations and records its availability status.
"""
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Accounts.User
alias Towerops.Organizations.Organization
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
name: String.t(),
organization_id: Ecto.UUID.t(),
user_id: Ecto.UUID.t(),
check_type: String.t(),
target_url: String.t(),
target_port: integer() | nil,
interval_seconds: integer(),
timeout_ms: integer(),
config: map(),
enabled: boolean(),
last_status: String.t() | nil,
last_response_time_ms: float() | nil,
last_checked_at: DateTime.t() | nil,
checks_count: integer(),
failures_count: integer(),
inserted_at: DateTime.t(),
updated_at: DateTime.t(),
organization: Organization.t() | NotLoaded.t(),
user: User.t() | NotLoaded.t()
}
@check_types ~w(http tcp dns ssl icmp)
schema "beacon_monitors" do
field :name, :string
field :check_type, :string, default: "http"
field :target_url, :string
field :target_port, :integer
field :interval_seconds, :integer, default: 60
field :timeout_ms, :integer, default: 5000
field :config, :map, default: %{}
field :enabled, :boolean, default: true
field :last_status, :string
field :last_response_time_ms, :float
field :last_checked_at, :utc_datetime_usec
field :checks_count, :integer, default: 0
field :failures_count, :integer, default: 0
belongs_to :organization, Organization
belongs_to :user, User
timestamps()
end
@doc """
Builds a changeset for creating or updating a beacon monitor.
"""
@spec changeset(t(), map()) :: Ecto.Changeset.t()
def changeset(beacon, attrs) do
beacon
|> cast(attrs, [
:name,
:check_type,
:target_url,
:target_port,
:interval_seconds,
:timeout_ms,
:config,
:enabled,
:organization_id,
:user_id,
:last_status,
:last_response_time_ms,
:last_checked_at,
:checks_count,
:failures_count
])
|> validate_required([:name, :check_type, :target_url, :interval_seconds, :timeout_ms, :organization_id, :user_id])
|> validate_inclusion(:check_type, @check_types)
|> validate_number(:interval_seconds, greater_than: 0, less_than: 86_401)
|> validate_number(:timeout_ms, greater_than: 0, less_than: 60_001)
|> validate_format(:target_url, ~r/^(https?:\/\/|tcp:\/\/).+|^[\w.-]+$/)
|> validate_port_or_default()
end
# Port is optional for HTTP, DNS, and SSL checks
defp validate_port_or_default(cs) do
check_type = Ecto.Changeset.get_field(cs, :check_type)
if check_type in ~w(http dns ssl) do
cs
else
target_port = Ecto.Changeset.get_field(cs, :target_port)
if is_integer(target_port) and target_port > 0 and target_port < 65_536 do
cs
else
add_error(cs, :target_port, "must be between 1 and 65535")
end
end
end
end

View file

@ -1,54 +0,0 @@
defmodule Towerops.Beacons.BeaconMonitorQuery do
@moduledoc """
Query builder for `BeaconMonitor` records.
Provides composable filter and ordering functions that can be pipelined
to build complex queries.
"""
import Ecto.Query
alias Towerops.Beacons.BeaconMonitor
@doc """
Returns a base query on the `beacon_monitors` table.
"""
@spec base() :: Ecto.Query.t()
def base, do: from(b in BeaconMonitor, as: :beacon_monitor)
@doc """
Filters to beacons belonging to the given organization.
"""
@spec for_organization(Ecto.Query.t(), Ecto.UUID.t()) :: Ecto.Query.t()
def for_organization(query, organization_id), do: from(q in query, where: q.organization_id == ^organization_id)
@doc """
Filters to beacons created by the given user.
"""
@spec for_user(Ecto.Query.t(), Ecto.UUID.t()) :: Ecto.Query.t()
def for_user(query, user_id), do: from(q in query, where: q.user_id == ^user_id)
@doc """
Filters to only enabled beacons.
"""
@spec enabled(Ecto.Query.t()) :: Ecto.Query.t()
def enabled(query), do: from(q in query, where: q.enabled == true)
@doc """
Filters to beacons of the given check type.
"""
@spec of_type(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
def of_type(query, check_type), do: from(q in query, where: q.check_type == ^check_type)
@doc """
Orders beacons by name ascending.
"""
@spec order_by_name(Ecto.Query.t()) :: Ecto.Query.t()
def order_by_name(query), do: from(q in query, order_by: [asc: q.name])
@doc """
Preloads organization and user associations.
"""
@spec preload_all(Ecto.Query.t()) :: Ecto.Query.t()
def preload_all(query), do: from(q in query, preload: [:organization, :user])
end

View file

@ -229,7 +229,6 @@ defmodule Towerops.Snmp.Discovery do
{:ok, transceivers} = with_timeout(client_opts, fn -> Base.discover_transceivers(client_opts) end, timeouts) {:ok, transceivers} = with_timeout(client_opts, fn -> Base.discover_transceivers(client_opts) end, timeouts)
Logger.info("Discovering printer supplies...", device_id: device.id) Logger.info("Discovering printer supplies...", device_id: device.id)
{:ok, printer_supplies} = {:ok, printer_supplies} =
with_timeout(client_opts, fn -> Base.discover_printer_supplies(client_opts) end, timeouts) with_timeout(client_opts, fn -> Base.discover_printer_supplies(client_opts) end, timeouts)
@ -317,8 +316,7 @@ defmodule Towerops.Snmp.Discovery do
timeout: timeouts[:slow], timeout: timeouts[:slow],
default: {:error, :interface_discovery_timeout} default: {:error, :interface_discovery_timeout}
) do ) do
{:ok, _ifaces} = ok -> {:ok, _ifaces} = ok -> ok
ok
{:error, :interface_discovery_timeout} -> {:error, :interface_discovery_timeout} ->
Logger.error("Interface discovery timed out - aborting to prevent data loss", device_id: device_id) Logger.error("Interface discovery timed out - aborting to prevent data loss", device_id: device_id)
@ -336,8 +334,7 @@ defmodule Towerops.Snmp.Discovery do
timeout: timeouts[:slow], timeout: timeouts[:slow],
default: {:error, :sensor_discovery_timeout} default: {:error, :sensor_discovery_timeout}
) do ) do
{:ok, _sensors} = ok -> {:ok, _sensors} = ok -> ok
ok
{:error, :sensor_discovery_timeout} -> {:error, :sensor_discovery_timeout} ->
Logger.error("Sensor discovery timed out - aborting to prevent data loss", device_id: device_id) Logger.error("Sensor discovery timed out - aborting to prevent data loss", device_id: device_id)
@ -1607,7 +1604,8 @@ defmodule Towerops.Snmp.Discovery do
@spec update_device_name_from_snmp(DeviceSchema.t(), system_info()) :: @spec update_device_name_from_snmp(DeviceSchema.t(), system_info()) ::
{:ok, DeviceSchema.t()} | {:error, term()} {:ok, DeviceSchema.t()} | {:error, term()}
defp update_device_name_from_snmp(%{name: name} = device, _system_info) when is_binary(name) and name != "" do defp update_device_name_from_snmp(%{name: name} = device, _system_info)
when is_binary(name) and name != "" do
Logger.info("Device name already set or no sysName, skipping update", device_id: device.id) Logger.info("Device name already set or no sysName, skipping update", device_id: device.id)
{:ok, device} {:ok, device}
end end

View file

@ -378,12 +378,6 @@ defmodule ToweropsWeb.Layouts do
icon="hero-signal" icon="hero-signal"
label={t("Coverage")} label={t("Coverage")}
/> />
<.sidebar_link
navigate={~p"/beacons"}
active={@active_page == "beacons"}
icon="hero-radio"
label={t("Beacon Monitors")}
/>
<%!-- <.sidebar_link <%!-- <.sidebar_link
navigate={~p"/weathermap"} navigate={~p"/weathermap"}
active={@active_page == "weathermap"} active={@active_page == "weathermap"}
@ -595,9 +589,6 @@ defmodule ToweropsWeb.Layouts do
<.mobile_nav_link navigate={~p"/network-map"} active={@active_page == "network-map"}> <.mobile_nav_link navigate={~p"/network-map"} active={@active_page == "network-map"}>
{t("Network Map")} {t("Network Map")}
</.mobile_nav_link> </.mobile_nav_link>
<.mobile_nav_link navigate={~p"/beacons"} active={@active_page == "beacons"}>
{t("Beacon Monitors")}
</.mobile_nav_link>
<%!-- <.mobile_nav_link navigate={~p"/weathermap"} active={@active_page == "weathermap"}> <%!-- <.mobile_nav_link navigate={~p"/weathermap"} active={@active_page == "weathermap"}>
<.icon name="hero-signal" class="size-5" /> {t("Weathermap")} <.icon name="hero-signal" class="size-5" /> {t("Weathermap")}
</.mobile_nav_link> --%> </.mobile_nav_link> --%>
@ -939,12 +930,6 @@ defmodule ToweropsWeb.Layouts do
> >
Exceptions Exceptions
</.link> </.link>
<.link
navigate={~p"/admin/beacons"}
class="inline-flex items-center px-1 pt-1 pb-4 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
>
{t("Beacon Monitors")}
</.link>
</div> </div>
</div> </div>
<div class="flex items-center gap-1 sm:gap-2"> <div class="flex items-center gap-1 sm:gap-2">
@ -974,9 +959,6 @@ defmodule ToweropsWeb.Layouts do
<.mobile_nav_link navigate={~p"/admin/errors"} active={false}> <.mobile_nav_link navigate={~p"/admin/errors"} active={false}>
Exceptions Exceptions
</.mobile_nav_link> </.mobile_nav_link>
<.mobile_nav_link navigate={~p"/admin/beacons"} active={false}>
{t("Beacon Monitors")}
</.mobile_nav_link>
</div> </div>
</div> </div>
</nav> </nav>

View file

@ -1,115 +0,0 @@
defmodule ToweropsWeb.Admin.BeaconMonitorLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
import ToweropsWeb.BeaconMonitorLive.Helpers
import ToweropsWeb.GettextHelpers
alias Towerops.Beacons
alias Towerops.Beacons.BeaconMonitor
on_mount {ToweropsWeb.UserAuth, :require_superuser}
@impl true
def mount(_params, _session, socket) do
assign_mount_state(socket, connected?(socket))
end
defp assign_mount_state(socket, true = _connected) do
:ok = Phoenix.PubSub.subscribe(Towerops.PubSub, "beacons:*")
{:ok, ref} = :timer.send_interval(60_000, :tick)
socket
|> assign_common()
|> assign(:timer_ref, ref)
|> then(&{:ok, &1})
end
defp assign_mount_state(socket, _not_connected) do
socket
|> assign_common()
|> assign(:timer_ref, nil)
|> then(&{:ok, &1})
end
defp assign_common(socket) do
beacons = Beacons.list_all_beacons()
socket
|> assign(:page_title, t("All Beacons"))
|> assign(:now, DateTime.utc_now())
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> stream(:beacons, beacons, reset: true)
|> assign(:has_beacons, beacons != [])
end
@impl true
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
@impl true
def handle_event("toggle_beacon", %{"id" => id}, socket) do
socket =
with %BeaconMonitor{} = beacon <- Beacons.get_beacon(id),
{:ok, _updated} <- Beacons.toggle_beacon(beacon) do
reload_beacons(socket)
else
_error -> put_flash(socket, :error, t_equipment("Beacon not found"))
end
{:noreply, socket}
end
@impl true
def handle_event("delete_beacon", %{"id" => id}, socket) do
socket =
with %BeaconMonitor{} = beacon <- Beacons.get_beacon(id),
{:ok, _deleted} <- Beacons.delete_beacon(beacon) do
socket
|> put_flash(:info, t_equipment("Beacon deleted successfully"))
|> reload_beacons()
else
_error -> put_flash(socket, :error, t_equipment("Failed to delete beacon"))
end
{:noreply, socket}
end
@impl true
def handle_info(:tick, socket) do
{:noreply, assign(socket, :now, DateTime.utc_now())}
end
@impl true
def handle_info({:beacon_updated, _beacon_id}, socket) do
{:noreply, reload_beacons(socket)}
end
@impl true
def handle_info({:beacon_created, _beacon_id}, socket) do
{:noreply, reload_beacons(socket)}
end
@impl true
def handle_info({:beacon_deleted, _beacon_id}, socket) do
{:noreply, reload_beacons(socket)}
end
defp reload_beacons(socket) do
beacons = Beacons.list_all_beacons()
socket
|> stream(:beacons, beacons, reset: true)
|> assign(:has_beacons, beacons != [])
end
@impl true
def terminate(_reason, socket) do
_ = cancel_timer(socket.assigns[:timer_ref])
:ok
end
defp cancel_timer(nil), do: :ok
defp cancel_timer(ref), do: :timer.cancel(ref)
end

View file

@ -1,114 +0,0 @@
<Layouts.admin flash={@flash} timezone={@timezone}>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{t("All Beacons")}</h1>
<p class="text-gray-600 dark:text-gray-400">
{t_equipment("Beacon monitors across all organizations")}
</p>
</div>
<%= if !@has_beacons do %>
<div class="text-center py-16">
<.icon name="hero-signal" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
{t("No beacon monitors")}
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{t_equipment("No beacon monitors have been created yet.")}
</p>
</div>
<% else %>
<div class="overflow-x-auto">
<.table
id="admin-beacons-table"
rows={@streams.beacons}
>
<:col :let={{_id, beacon}} label={t_equipment("Name")}>
<span class="font-medium text-gray-900 dark:text-white">{beacon.name}</span>
</:col>
<:col :let={{_id, beacon}} label={t("Organization")}>
<span class="text-sm text-gray-600 dark:text-gray-400">
{beacon.organization.name}
</span>
</:col>
<:col :let={{_id, beacon}} label={t_equipment("User")}>
<span class="text-sm text-gray-600 dark:text-gray-400">
{beacon.user.email}
</span>
</:col>
<:col :let={{_id, beacon}} label={t_equipment("Check Type")}>
<span class={[
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset",
check_type_badge_class(beacon.check_type)
]}>
{check_type_label(beacon.check_type)}
</span>
</:col>
<:col :let={{_id, beacon}} label={t_equipment("Target URL")}>
<span
class="text-sm text-gray-600 dark:text-gray-400 font-mono max-w-[200px] truncate block"
title={beacon.target_url}
>
{beacon.target_url}
</span>
</:col>
<:col :let={{_id, beacon}} label={t_equipment("Last Status")}>
<span class={[
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium",
status_badge_class(beacon.last_status)
]}>
<span class={["h-1.5 w-1.5 rounded-full", status_dot_class(beacon.last_status)]} />
{status_label(beacon.last_status)}
</span>
</:col>
<:col :let={{_id, beacon}} label={t("Enabled")}>
<button
id={"admin-toggle-beacon-#{beacon.id}"}
type="button"
phx-click="toggle_beacon"
phx-value-id={beacon.id}
class={[
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
if(beacon.enabled, do: "bg-blue-600", else: "bg-gray-300 dark:bg-gray-600")
]}
role="switch"
aria-checked={beacon.enabled}
>
<span class={[
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
if(beacon.enabled, do: "translate-x-5", else: "translate-x-0")
]} />
</button>
</:col>
<:col :let={{_id, beacon}} label={t_equipment("Last Checked")}>
<%= if beacon.last_checked_at do %>
<.timestamp datetime={beacon.last_checked_at} timezone={@timezone} now={@now} />
<% else %>
<span class="text-sm text-gray-400 dark:text-gray-500">{t_equipment("Never")}</span>
<% end %>
</:col>
<:action :let={{_id, beacon}}>
<.button
id={"admin-delete-beacon-#{beacon.id}"}
type="button"
phx-click="delete_beacon"
phx-value-id={beacon.id}
data-confirm={t_equipment("Are you sure you want to delete this beacon monitor?")}
variant="danger"
>
{t("Delete")}
</.button>
</:action>
</.table>
</div>
<% end %>
</div>
</Layouts.admin>

View file

@ -1,235 +0,0 @@
defmodule ToweropsWeb.BeaconMonitorLive.FormComponent do
@moduledoc false
use ToweropsWeb, :live_component
import ToweropsWeb.GettextHelpers
alias Towerops.Beacons
alias Towerops.Beacons.BeaconMonitor
@check_type_options [
{"HTTP", "http"},
{"TCP", "tcp"},
{"DNS", "dns"},
{"SSL", "ssl"},
{"ICMP", "icmp"}
]
@impl true
def render(assigns) do
~H"""
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div
class="fixed inset-0 bg-gray-500/75 dark:bg-gray-950/75 transition-opacity"
phx-click="close"
phx-target={@myself}
>
</div>
<div class="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-lg w-full">
<.form
for={@form}
id={"beacon-form-#{@action}"}
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{title_for_action(@action)}
</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{subtitle_for_action(@action)}
</p>
</div>
<button
type="button"
phx-click="close"
phx-target={@myself}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<.icon name="hero-x-mark" class="h-6 w-6" />
</button>
</div>
<div class="space-y-4">
<.input
field={@form[:name]}
type="text"
label={t_equipment("Name")}
placeholder="e.g., Main Website"
required
/>
<.input
field={@form[:check_type]}
type="select"
label={t_equipment("Check Type")}
options={@check_type_options_formatted}
/>
<.input
field={@form[:target_url]}
type="text"
label={t_equipment("Target URL")}
placeholder="https://example.com/health"
required
/>
<.input
field={@form[:target_port]}
type="number"
label={t_equipment("Target Port")}
placeholder="443"
/>
<div class="grid grid-cols-2 gap-4">
<.input
field={@form[:interval_seconds]}
type="number"
label={t_equipment("Interval (seconds)")}
/>
<.input
field={@form[:timeout_ms]}
type="number"
label={t_equipment("Timeout (ms)")}
/>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900/50 px-6 py-4 flex items-center justify-end gap-3 rounded-b-lg">
<button
type="button"
phx-click="close"
phx-target={@myself}
class="px-3 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
{t("Cancel")}
</button>
<.button type="submit" variant="primary" phx-disable-with={t_equipment("Saving...")}>
{submit_label(@action)}
</.button>
</div>
</.form>
</div>
</div>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, socket}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign(:check_type_options_formatted, @check_type_options)
|> assign_form()
|> assign(:action, assigns[:action] || :new)
{:ok, socket}
end
@impl true
def handle_event("validate", %{"beacon_monitor" => params}, socket) do
changeset =
%BeaconMonitor{}
|> BeaconMonitor.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
@impl true
def handle_event("save", %{"beacon_monitor" => params}, socket) do
save_beacon(socket, socket.assigns.action, params)
end
@impl true
def handle_event("close", _params, socket) do
send(self(), {__MODULE__, :close})
{:noreply, socket}
end
defp assign_form(socket) do
beacon = socket.assigns[:beacon]
action = socket.assigns[:action] || :new
form_attrs = form_attrs_for_action(action, beacon)
changeset =
beacon
|> beacon_or_new()
|> BeaconMonitor.changeset(form_attrs)
assign(socket, :form, to_form(changeset))
end
defp form_attrs_for_action(:edit, %BeaconMonitor{} = beacon) do
%{
"name" => beacon.name,
"check_type" => beacon.check_type,
"target_url" => beacon.target_url,
"target_port" => beacon.target_port,
"interval_seconds" => beacon.interval_seconds,
"timeout_ms" => beacon.timeout_ms
}
end
defp form_attrs_for_action(_action, _beacon) do
%{"check_type" => "http", "interval_seconds" => 60, "timeout_ms" => 5000}
end
defp beacon_or_new(%BeaconMonitor{} = beacon), do: beacon
defp beacon_or_new(_not_beacon), do: %BeaconMonitor{}
defp save_beacon(socket, :edit, params) do
beacon = socket.assigns.beacon
organization_id = socket.assigns.current_scope.organization.id
user_id = socket.assigns.current_scope.user.id
params_with_ownership = Map.merge(params, %{"organization_id" => organization_id, "user_id" => user_id})
case Beacons.update_beacon(beacon, params_with_ownership) do
{:ok, _updated_beacon} ->
send(self(), {__MODULE__, :saved})
{:noreply, put_flash(socket, :info, t_equipment("Beacon updated successfully"))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp save_beacon(socket, _action, params) do
organization_id = socket.assigns.current_scope.organization.id
user_id = socket.assigns.current_scope.user.id
params_with_ownership = Map.merge(params, %{"organization_id" => organization_id, "user_id" => user_id})
case Beacons.create_beacon(params_with_ownership) do
{:ok, _beacon} ->
send(self(), {__MODULE__, :saved})
{:noreply, put_flash(socket, :info, t_equipment("Beacon created successfully"))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp title_for_action(:edit), do: t_equipment("Edit Beacon Monitor")
defp title_for_action(_action), do: t_equipment("Create Beacon Monitor")
defp subtitle_for_action(:edit), do: t_equipment("Update the configuration for this beacon monitor")
defp subtitle_for_action(_action), do: t_equipment("Configure a new beacon monitor to check your endpoint")
defp submit_label(:edit), do: t_equipment("Save Changes")
defp submit_label(_action), do: t_equipment("Create")
end

View file

@ -1,86 +0,0 @@
defmodule ToweropsWeb.BeaconMonitorLive.Helpers do
@moduledoc """
Helper functions for Beacon Monitor LiveViews.
"""
use Gettext, backend: ToweropsWeb.Gettext
import ToweropsWeb.GettextHelpers
@doc """
Returns CSS classes for the check type badge based on check type.
"""
def check_type_badge_class("http"),
do: "bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-300 dark:ring-blue-400/30"
def check_type_badge_class("tcp"),
do: "bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-400/30"
def check_type_badge_class("dns"),
do:
"bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-300 dark:ring-purple-400/30"
def check_type_badge_class("ssl"),
do: "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-300 dark:ring-amber-400/30"
def check_type_badge_class("icmp"),
do: "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-900/30 dark:text-rose-300 dark:ring-rose-400/30"
def check_type_badge_class(_unknown),
do: "bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-400/30"
@doc """
Returns the human-readable label for a check type.
"""
def check_type_label("http"), do: "HTTP"
def check_type_label("tcp"), do: "TCP"
def check_type_label("dns"), do: "DNS"
def check_type_label("ssl"), do: "SSL"
def check_type_label("icmp"), do: "ICMP"
def check_type_label(_unknown), do: t_equipment("Unknown")
@doc """
Returns CSS classes for the status badge based on last status.
"""
def status_badge_class(nil),
do: "bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20"
def status_badge_class("up"),
do: "bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-400/30"
def status_badge_class("down"),
do: "bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-300 dark:ring-red-400/30"
def status_badge_class(_unknown),
do:
"bg-yellow-50 text-yellow-700 ring-yellow-600/20 dark:bg-yellow-900/30 dark:text-yellow-300 dark:ring-yellow-400/30"
@doc """
Returns CSS classes for the status dot (a small colored circle).
"""
def status_dot_class(nil), do: "bg-gray-400"
def status_dot_class("up"), do: "bg-green-500"
def status_dot_class("down"), do: "bg-red-500"
def status_dot_class(_unknown), do: "bg-yellow-500"
@doc """
Returns the human-readable label for a beacon status.
"""
def status_label(nil), do: t_equipment("Never checked")
def status_label("up"), do: t_equipment("Up")
def status_label("down"), do: t_equipment("Down")
def status_label(unknown), do: unknown
@doc """
Formats a response time in milliseconds to a human-readable string.
"""
def format_response_time(nil), do: ""
def format_response_time(ms) when ms < 1_000 do
"#{Float.round(ms, 0)}ms"
end
def format_response_time(ms) when ms >= 1_000 do
"#{Float.round(ms / 1000, 2)}s"
end
end

View file

@ -1,180 +0,0 @@
defmodule ToweropsWeb.BeaconMonitorLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
import ToweropsWeb.BeaconMonitorLive.Helpers
import ToweropsWeb.GettextHelpers
alias Towerops.Beacons
alias Towerops.Beacons.BeaconMonitor
alias ToweropsWeb.BeaconMonitorLive.FormComponent
@impl true
def mount(_params, _session, socket) do
assign_mount_state(socket, connected?(socket))
end
defp assign_mount_state(socket, true = _connected) do
organization = socket.assigns.current_scope.organization
:ok = Phoenix.PubSub.subscribe(Towerops.PubSub, "beacons:#{organization.id}")
{:ok, ref} = :timer.send_interval(60_000, :tick)
socket
|> assign_common()
|> assign(:timer_ref, ref)
|> then(&{:ok, &1})
end
defp assign_mount_state(socket, _not_connected) do
socket
|> assign_common()
|> assign(:timer_ref, nil)
|> then(&{:ok, &1})
end
defp assign_common(socket) do
organization = socket.assigns.current_scope.organization
current_user = socket.assigns.current_scope.user
beacons = Beacons.list_user_beacons(organization.id, current_user.id)
socket
|> assign(:page_title, t("Beacon Monitors"))
|> assign(:now, DateTime.utc_now())
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> stream(:beacons, beacons, reset: true)
|> assign(:has_beacons, beacons != [])
end
@impl true
def handle_params(params, _url, socket) do
socket = assign_modal_state(socket, socket.assigns.live_action, params)
{:noreply, socket}
end
defp assign_modal_state(socket, :new, _params) do
socket
|> assign(:show_modal, true)
|> assign(:modal_action, :new)
|> assign(:modal_beacon, nil)
end
defp assign_modal_state(socket, :edit, %{"id" => id}) do
organization = socket.assigns.current_scope.organization
beacon = Beacons.get_beacon_for_org(id, organization.id)
socket
|> assign(:show_modal, true)
|> assign(:modal_action, :edit)
|> assign(:modal_beacon, beacon)
end
defp assign_modal_state(socket, _live_action, _params) do
socket
|> assign(:show_modal, false)
|> assign(:modal_action, nil)
|> assign(:modal_beacon, nil)
end
@impl true
def handle_event("open_new", _params, socket) do
{:noreply, push_navigate(socket, to: ~p"/beacons/new")}
end
@impl true
def handle_event("open_edit", %{"id" => id}, socket) do
{:noreply, push_navigate(socket, to: ~p"/beacons/#{id}/edit")}
end
@impl true
def handle_event("toggle_beacon", %{"id" => id}, socket) do
organization = socket.assigns.current_scope.organization
socket =
with %BeaconMonitor{} = beacon <- Beacons.get_beacon_for_org(id, organization.id),
{:ok, _updated} <- Beacons.toggle_beacon(beacon) do
reload_beacons(socket)
else
_error -> put_flash(socket, :error, t_equipment("Beacon not found"))
end
{:noreply, socket}
end
@impl true
def handle_event("delete_beacon", %{"id" => id}, socket) do
organization = socket.assigns.current_scope.organization
socket =
with %BeaconMonitor{} = beacon <- Beacons.get_beacon_for_org(id, organization.id),
{:ok, _deleted} <- Beacons.delete_beacon(beacon) do
socket
|> put_flash(:info, t_equipment("Beacon deleted successfully"))
|> reload_beacons()
else
_error -> put_flash(socket, :error, t_equipment("Failed to delete beacon"))
end
{:noreply, socket}
end
@impl true
def handle_event("close_modal", _params, socket) do
{:noreply, push_navigate(socket, to: ~p"/beacons")}
end
@impl true
def handle_info(:tick, socket) do
{:noreply, assign(socket, :now, DateTime.utc_now())}
end
@impl true
def handle_info({FormComponent, :saved}, socket) do
socket
|> reload_beacons()
|> push_navigate(to: ~p"/beacons")
|> then(&{:noreply, &1})
end
@impl true
def handle_info({FormComponent, :close}, socket) do
{:noreply, push_navigate(socket, to: ~p"/beacons")}
end
@impl true
def handle_info({:beacon_updated, _beacon_id}, socket) do
{:noreply, reload_beacons(socket)}
end
@impl true
def handle_info({:beacon_created, _beacon_id}, socket) do
{:noreply, reload_beacons(socket)}
end
@impl true
def handle_info({:beacon_deleted, _beacon_id}, socket) do
{:noreply, reload_beacons(socket)}
end
defp reload_beacons(socket) do
organization = socket.assigns.current_scope.organization
current_user = socket.assigns.current_scope.user
beacons = Beacons.list_user_beacons(organization.id, current_user.id)
socket
|> stream(:beacons, beacons, reset: true)
|> assign(:has_beacons, beacons != [])
end
@impl true
def terminate(_reason, socket) do
_ = cancel_timer(socket.assigns[:timer_ref])
:ok
end
defp cancel_timer(nil), do: :ok
defp cancel_timer(ref), do: :timer.cancel(ref)
end

View file

@ -1,150 +0,0 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="beacons"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
{@page_title}
<:subtitle>{t_equipment("Monitor your endpoints from distributed locations")}</:subtitle>
<:actions>
<.button phx-click="open_new" variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> {t("Add Beacon Monitor")}
</.button>
</:actions>
</.header>
<%= if !@has_beacons do %>
<div class="text-center py-16">
<.icon name="hero-signal" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
{t("No beacon monitors")}
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{t_equipment("Get started by creating your first beacon monitor.")}
</p>
<div class="mt-6">
<.button phx-click="open_new" variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> {t("Add Beacon Monitor")}
</.button>
</div>
</div>
<% else %>
<div class="mt-6 overflow-x-auto">
<.table
id="beacons-table"
rows={@streams.beacons}
>
<:col :let={{_id, beacon}} label={t_equipment("Name")}>
<span class="font-medium text-gray-900 dark:text-white">{beacon.name}</span>
</:col>
<:col :let={{_id, beacon}} label={t_equipment("Check Type")}>
<span class={[
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset",
check_type_badge_class(beacon.check_type)
]}>
{check_type_label(beacon.check_type)}
</span>
</:col>
<:col :let={{_id, beacon}} label={t_equipment("Target URL")}>
<span
class="text-sm text-gray-600 dark:text-gray-400 font-mono max-w-[200px] truncate block"
title={beacon.target_url}
>
{beacon.target_url}
</span>
<%= if beacon.check_type in ~w(tcp icmp) && beacon.target_port do %>
<span class="text-xs text-gray-500 dark:text-gray-500">:{beacon.target_port}</span>
<% end %>
</:col>
<:col :let={{_id, beacon}} label={t_equipment("Last Status")}>
<span class={[
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium",
status_badge_class(beacon.last_status)
]}>
<span class={["h-1.5 w-1.5 rounded-full", status_dot_class(beacon.last_status)]} />
{status_label(beacon.last_status)}
</span>
</:col>
<:col :let={{_id, beacon}} label={t_equipment("Response Time")}>
<%= if beacon.last_response_time_ms do %>
<span class="text-sm text-gray-600 dark:text-gray-400 font-mono">
{format_response_time(beacon.last_response_time_ms)}
</span>
<% else %>
<span class="text-sm text-gray-400 dark:text-gray-500">&mdash;</span>
<% end %>
</:col>
<:col :let={{_id, beacon}} label={t_equipment("Last Checked")}>
<%= if beacon.last_checked_at do %>
<.timestamp datetime={beacon.last_checked_at} timezone={@timezone} now={@now} />
<% else %>
<span class="text-sm text-gray-400 dark:text-gray-500">{t_equipment("Never")}</span>
<% end %>
</:col>
<:col :let={{_id, beacon}} label={t("Enabled")}>
<button
id={"toggle-beacon-#{beacon.id}"}
type="button"
phx-click="toggle_beacon"
phx-value-id={beacon.id}
class={[
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
if(beacon.enabled, do: "bg-blue-600", else: "bg-gray-300 dark:bg-gray-600")
]}
role="switch"
aria-checked={beacon.enabled}
>
<span class={[
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
if(beacon.enabled, do: "translate-x-5", else: "translate-x-0")
]} />
</button>
</:col>
<:action :let={{_id, beacon}}>
<div class="flex items-center gap-2">
<.button
id={"edit-beacon-#{beacon.id}"}
type="button"
phx-click="open_edit"
phx-value-id={beacon.id}
variant="secondary"
>
{t("Edit")}
</.button>
<.button
id={"delete-beacon-#{beacon.id}"}
type="button"
phx-click="delete_beacon"
phx-value-id={beacon.id}
data-confirm={t_equipment("Are you sure you want to delete this beacon monitor?")}
variant="danger"
>
{t("Delete")}
</.button>
</div>
</:action>
</.table>
</div>
<% end %>
<!-- Create/Edit Beacon Modal -->
<%= if @show_modal do %>
<.live_component
module={ToweropsWeb.BeaconMonitorLive.FormComponent}
id={
if @modal_action == :edit, do: "beacon-form-#{@modal_beacon.id}", else: "beacon-form-new"
}
action={@modal_action}
beacon={@modal_beacon}
current_scope={@current_scope}
/>
<% end %>
</Layouts.authenticated>

View file

@ -366,7 +366,6 @@ defmodule ToweropsWeb.Router do
live "/audit", AuditLive.Index, :index live "/audit", AuditLive.Index, :index
live "/monitoring", MonitoringLive, :index live "/monitoring", MonitoringLive, :index
live "/agents", AgentLive.Index, :index live "/agents", AgentLive.Index, :index
live "/beacons", BeaconMonitorLive.Index, :index
live "/security", SecurityLive.Index, :index live "/security", SecurityLive.Index, :index
end end
end end
@ -463,11 +462,6 @@ defmodule ToweropsWeb.Router do
live "/agents/:id/edit", AgentLive.Edit, :edit live "/agents/:id/edit", AgentLive.Edit, :edit
live "/agents/:id", AgentLive.Show, :show live "/agents/:id", AgentLive.Show, :show
# Beacon Monitor routes
live "/beacons", BeaconMonitorLive.Index, :index
live "/beacons/new", BeaconMonitorLive.Index, :new
live "/beacons/:id/edit", BeaconMonitorLive.Index, :edit
# Site routes # Site routes
live "/sites", SiteLive.Index, :index live "/sites", SiteLive.Index, :index
live "/sites/new", SiteLive.Form, :new live "/sites/new", SiteLive.Form, :new

View file

@ -5455,26 +5455,3 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "— WISP Network Operator" msgid "— WISP Network Operator"
msgstr "" msgstr ""
#: lib/towerops_web/live/beacon_monitor_live/index.ex:43
#, elixir-autogen, elixir-format
msgid "Beacon Monitors"
msgstr "Beacon Monitors"
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:12
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:26
#, elixir-autogen, elixir-format
msgid "Add Beacon Monitor"
msgstr "Add Beacon Monitor"
#: lib/towerops_web/admin/beacon_monitor_live/index.ex:40
#: lib/towerops_web/admin/beacon_monitor_live/index.html.heex:4
#, elixir-autogen, elixir-format
msgid "All Beacons"
msgstr "All Beacons"
#: lib/towerops_web/admin/beacon_monitor_live/index.html.heex:13
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:20
#, elixir-autogen, elixir-format
msgid "No beacon monitors"
msgstr "No beacon monitors"

View file

@ -318,176 +318,3 @@ msgstr "Please select exactly 2 backups to compare"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unable to resolve alert" msgid "Unable to resolve alert"
msgstr "" msgstr ""
#: lib/towerops_web/live/beacon_monitor_live/helpers.ex:69
#, elixir-autogen, elixir-format
msgid "Down"
msgstr "Down"
#: lib/towerops_web/live/beacon_monitor_live/helpers.ex:67
#, elixir-autogen, elixir-format
msgid "Never checked"
msgstr "Never checked"
#: lib/towerops_web/live/beacon_monitor_live/helpers.ex:68
#, elixir-autogen, elixir-format
msgid "Up"
msgstr "Up"
#: lib/towerops_web/live/beacon_monitor_live/helpers.ex:39
#, elixir-autogen, elixir-format
msgid "Unknown"
msgstr "Unknown"
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:22
#, elixir-autogen, elixir-format
msgid "Get started by creating your first beacon monitor."
msgstr "Get started by creating your first beacon monitor."
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:9
#, elixir-autogen, elixir-format
msgid "Monitor your endpoints from distributed locations"
msgstr "Monitor your endpoints from distributed locations"
#: lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex:6
#, elixir-autogen, elixir-format
msgid "Beacon monitors across all organizations"
msgstr "Beacon monitors across all organizations"
#: lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex:15
#, elixir-autogen, elixir-format
msgid "No beacon monitors have been created yet."
msgstr "No beacon monitors have been created yet."
#: lib/towerops_web/live/admin/beacon_monitor_live/index.ex:71
#: lib/towerops_web/live/beacon_monitor_live/index.ex:116
#, elixir-autogen, elixir-format
msgid "Beacon deleted successfully"
msgstr "Beacon deleted successfully"
#: lib/towerops_web/live/admin/beacon_monitor_live/index.ex:59
#: lib/towerops_web/live/beacon_monitor_live/index.ex:102
#, elixir-autogen, elixir-format
msgid "Beacon not found"
msgstr "Beacon not found"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:199
#, elixir-autogen, elixir-format
msgid "Beacon updated successfully"
msgstr "Beacon updated successfully"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:215
#, elixir-autogen, elixir-format
msgid "Beacon created successfully"
msgstr "Beacon created successfully"
#: lib/towerops_web/live/admin/beacon_monitor_live/index.ex:74
#: lib/towerops_web/live/beacon_monitor_live/index.ex:119
#, elixir-autogen, elixir-format
msgid "Failed to delete beacon"
msgstr "Failed to delete beacon"
#: lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex:99
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:122
#, elixir-autogen, elixir-format
msgid "Are you sure you want to delete this beacon monitor?"
msgstr "Are you sure you want to delete this beacon monitor?"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:223
#, elixir-autogen, elixir-format
msgid "Create Beacon Monitor"
msgstr "Create Beacon Monitor"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:222
#, elixir-autogen, elixir-format
msgid "Edit Beacon Monitor"
msgstr "Edit Beacon Monitor"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:226
#, elixir-autogen, elixir-format
msgid "Configure a new beacon monitor to check your endpoint"
msgstr "Configure a new beacon monitor to check your endpoint"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:225
#, elixir-autogen, elixir-format
msgid "Update the configuration for this beacon monitor"
msgstr "Update the configuration for this beacon monitor"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:229
#, elixir-autogen, elixir-format
msgid "Create"
msgstr "Create"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:228
#, elixir-autogen, elixir-format
msgid "Save Changes"
msgstr "Save Changes"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:89
#, elixir-autogen, elixir-format
msgid "Interval (seconds)"
msgstr "Interval (seconds)"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:94
#, elixir-autogen, elixir-format
msgid "Timeout (ms)"
msgstr "Timeout (ms)"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:81
#, elixir-autogen, elixir-format
msgid "Target Port"
msgstr "Target Port"
#: lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex:49
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:73
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:49
#, elixir-autogen, elixir-format
msgid "Target URL"
msgstr "Target URL"
#: lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex:40
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:66
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:40
#, elixir-autogen, elixir-format
msgid "Check Type"
msgstr "Check Type"
#: lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex:85
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:78
#, elixir-autogen, elixir-format
msgid "Last Checked"
msgstr "Last Checked"
#: lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex:55
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:58
#, elixir-autogen, elixir-format
msgid "Last Status"
msgstr "Last Status"
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:68
#, elixir-autogen, elixir-format
msgid "Response Time"
msgstr "Response Time"
#: lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex:24
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:58
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:36
#, elixir-autogen, elixir-format
msgid "Name"
msgstr "Name"
#: lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex:89
#: lib/towerops_web/live/beacon_monitor_live/index.html.heex:82
#, elixir-autogen, elixir-format
msgid "Never"
msgstr "Never"
#: lib/towerops_web/live/beacon_monitor_live/form_component.ex:109
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr "Saving..."
#: lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "User"
msgstr "User"

View file

@ -1,34 +0,0 @@
defmodule Towerops.Repo.Migrations.CreateBeaconMonitors do
use Ecto.Migration
def change do
create table(:beacon_monitors, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all), null: false
add :check_type, :string, null: false, default: "http"
add :target_url, :string, null: false
add :target_port, :integer
add :interval_seconds, :integer, null: false, default: 60
add :timeout_ms, :integer, null: false, default: 5000
add :config, :map, default: "{}"
add :enabled, :boolean, null: false, default: true
add :last_status, :string
add :last_response_time_ms, :float
add :last_checked_at, :utc_datetime_usec
add :checks_count, :integer, null: false, default: 0
add :failures_count, :integer, null: false, default: 0
timestamps(type: :utc_datetime)
end
create index(:beacon_monitors, [:organization_id])
create index(:beacon_monitors, [:user_id])
create index(:beacon_monitors, [:check_type])
create index(:beacon_monitors, [:enabled])
end
end

View file

@ -1,27 +0,0 @@
defmodule Towerops.BeaconsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Towerops.Beacons` context.
"""
@doc """
Creates a beacon monitor fixture.
Accepts optional attrs to override defaults.
Requires `organization_id` and `user_id` to be provided.
"""
def beacon_monitor_fixture(organization_id, user_id, attrs \\ %{}) do
defaults = %{
name: "Test Beacon #{System.unique_integer()}",
check_type: "http",
target_url: "https://example.com",
organization_id: organization_id,
user_id: user_id,
interval_seconds: 60,
timeout_ms: 5000
}
{:ok, beacon} = Towerops.Beacons.create_beacon(Map.merge(defaults, attrs))
beacon
end
end

View file

@ -1,499 +0,0 @@
defmodule Towerops.BeaconsTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.BeaconsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Beacons
alias Towerops.Beacons.BeaconMonitor
describe "list_organization_beacons/1" do
test "returns beacons for organization" do
user = user_fixture()
org = organization_fixture(user.id)
_beacon = beacon_monitor_fixture(org.id, user.id, %{name: "My Beacon"})
beacons = Beacons.list_organization_beacons(org.id)
assert length(beacons) == 1
assert hd(beacons).name == "My Beacon"
end
test "returns empty list when no beacons" do
user = user_fixture()
org = organization_fixture(user.id)
assert Beacons.list_organization_beacons(org.id) == []
end
test "does not return beacons from other organizations" do
user = user_fixture()
org1 = organization_fixture(user.id, %{name: "Org 1"})
org2 = organization_fixture(user.id, %{name: "Org 2"})
_beacon = beacon_monitor_fixture(org1.id, user.id)
assert Beacons.list_organization_beacons(org2.id) == []
end
end
describe "list_user_beacons/2" do
test "returns only user's beacons" do
user1 = user_fixture()
user2 = user_fixture()
org = organization_fixture(user1.id)
_my_beacon = beacon_monitor_fixture(org.id, user1.id, %{name: "Mine"})
_their_beacon = beacon_monitor_fixture(org.id, user2.id, %{name: "Theirs"})
my_beacons = Beacons.list_user_beacons(org.id, user1.id)
assert length(my_beacons) == 1
assert hd(my_beacons).name == "Mine"
end
test "returns empty list when user has no beacons" do
user1 = user_fixture()
user2 = user_fixture()
org = organization_fixture(user1.id)
_beacon = beacon_monitor_fixture(org.id, user1.id)
assert Beacons.list_user_beacons(org.id, user2.id) == []
end
end
describe "list_all_beacons/0" do
test "returns all beacons across orgs" do
user = user_fixture()
org1 = organization_fixture(user.id, %{name: "Org 1"})
org2 = organization_fixture(user.id, %{name: "Org 2"})
_b1 = beacon_monitor_fixture(org1.id, user.id)
_b2 = beacon_monitor_fixture(org2.id, user.id)
all = Beacons.list_all_beacons()
assert length(all) >= 2
end
test "returns empty list when no beacons exist" do
assert Beacons.list_all_beacons() == []
end
end
describe "get_beacon/1" do
test "returns beacon by id" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
assert %BeaconMonitor{} = Beacons.get_beacon(beacon.id)
assert Beacons.get_beacon(beacon.id).id == beacon.id
end
test "returns nil for missing id" do
assert Beacons.get_beacon(Ecto.UUID.generate()) == nil
end
end
describe "get_beacon!/1" do
test "returns beacon by id" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
assert %BeaconMonitor{} = Beacons.get_beacon!(beacon.id)
assert Beacons.get_beacon!(beacon.id).id == beacon.id
end
test "raises for missing id" do
assert_raise Ecto.NoResultsError, fn ->
Beacons.get_beacon!(Ecto.UUID.generate())
end
end
end
describe "get_beacon_for_org/2" do
test "returns beacon in org" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
assert %BeaconMonitor{} = Beacons.get_beacon_for_org(beacon.id, org.id)
end
test "returns nil for beacon in other org" do
user = user_fixture()
org1 = organization_fixture(user.id, %{name: "Org 1"})
org2 = organization_fixture(user.id, %{name: "Org 2"})
beacon = beacon_monitor_fixture(org1.id, user.id)
assert Beacons.get_beacon_for_org(beacon.id, org2.id) == nil
end
end
describe "get_beacon_for_user/2" do
test "returns beacon for user" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
assert %BeaconMonitor{} = Beacons.get_beacon_for_user(beacon.id, user.id)
end
test "returns nil for beacon belonging to other user" do
user1 = user_fixture()
user2 = user_fixture()
org = organization_fixture(user1.id)
beacon = beacon_monitor_fixture(org.id, user1.id)
assert Beacons.get_beacon_for_user(beacon.id, user2.id) == nil
end
end
describe "create_beacon/1" do
test "creates with valid attrs" do
user = user_fixture()
org = organization_fixture(user.id)
attrs = %{
name: "Test HTTP Monitor",
check_type: "http",
target_url: "https://example.com",
interval_seconds: 30,
timeout_ms: 3000,
organization_id: org.id,
user_id: user.id
}
assert {:ok, %BeaconMonitor{} = beacon} = Beacons.create_beacon(attrs)
assert beacon.name == "Test HTTP Monitor"
assert beacon.check_type == "http"
assert beacon.target_url == "https://example.com"
assert beacon.interval_seconds == 30
assert beacon.timeout_ms == 3000
assert beacon.enabled == true
assert beacon.checks_count == 0
assert beacon.failures_count == 0
assert beacon.organization_id == org.id
assert beacon.user_id == user.id
end
test "rejects invalid check_type" do
user = user_fixture()
org = organization_fixture(user.id)
attrs = %{
name: "Bad Check",
check_type: "invalid",
target_url: "https://example.com",
interval_seconds: 60,
timeout_ms: 5000,
organization_id: org.id,
user_id: user.id
}
assert {:error, %Ecto.Changeset{}} = Beacons.create_beacon(attrs)
end
test "requires name" do
user = user_fixture()
org = organization_fixture(user.id)
attrs = %{
check_type: "http",
target_url: "https://example.com",
interval_seconds: 60,
timeout_ms: 5000,
organization_id: org.id,
user_id: user.id
}
assert {:error, changeset} = Beacons.create_beacon(attrs)
assert "can't be blank" in errors_on(changeset).name
end
test "requires target_url" do
user = user_fixture()
org = organization_fixture(user.id)
attrs = %{
name: "No URL",
check_type: "http",
interval_seconds: 60,
timeout_ms: 5000,
organization_id: org.id,
user_id: user.id
}
assert {:error, changeset} = Beacons.create_beacon(attrs)
assert "can't be blank" in errors_on(changeset).target_url
end
test "accepts valid check types" do
user = user_fixture()
org = organization_fixture(user.id)
for {check_type, extra_attrs} <- [
{"http", %{}},
{"dns", %{}},
{"ssl", %{}},
{"tcp", %{target_port: 443}},
{"icmp", %{target_port: 7}}
] do
attrs =
Map.merge(
%{
name: "Test #{check_type}",
check_type: check_type,
target_url: "https://example.com",
interval_seconds: 60,
timeout_ms: 5000,
organization_id: org.id,
user_id: user.id
},
extra_attrs
)
assert {:ok, %BeaconMonitor{check_type: ^check_type}} =
Beacons.create_beacon(attrs)
end
end
test "rejects interval_seconds outside valid range" do
user = user_fixture()
org = organization_fixture(user.id)
attrs = %{
name: "Bad Interval",
check_type: "http",
target_url: "https://example.com",
interval_seconds: 0,
timeout_ms: 5000,
organization_id: org.id,
user_id: user.id
}
assert {:error, changeset} = Beacons.create_beacon(attrs)
assert "must be greater than 0" in errors_on(changeset).interval_seconds
attrs = %{
name: "Bad Interval High",
check_type: "http",
target_url: "https://example.com",
interval_seconds: 86_401,
timeout_ms: 5000,
organization_id: org.id,
user_id: user.id
}
assert {:error, changeset} = Beacons.create_beacon(attrs)
assert "must be less than 86401" in errors_on(changeset).interval_seconds
end
test "rejects timeout_ms outside valid range" do
user = user_fixture()
org = organization_fixture(user.id)
attrs = %{
name: "Bad Timeout",
check_type: "http",
target_url: "https://example.com",
interval_seconds: 60,
timeout_ms: 0,
organization_id: org.id,
user_id: user.id
}
assert {:error, changeset} = Beacons.create_beacon(attrs)
assert "must be greater than 0" in errors_on(changeset).timeout_ms
attrs = %{
name: "Bad Timeout High",
check_type: "http",
target_url: "https://example.com",
interval_seconds: 60,
timeout_ms: 60_001,
organization_id: org.id,
user_id: user.id
}
assert {:error, changeset} = Beacons.create_beacon(attrs)
assert "must be less than 60001" in errors_on(changeset).timeout_ms
end
end
describe "update_beacon/2" do
test "updates beacon name" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
assert {:ok, %BeaconMonitor{name: "Updated Name"}} =
Beacons.update_beacon(beacon, %{name: "Updated Name"})
end
test "rejects invalid update" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
assert {:error, %Ecto.Changeset{}} =
Beacons.update_beacon(beacon, %{check_type: "invalid"})
end
test "preserves unchanged fields" do
user = user_fixture()
org = organization_fixture(user.id)
beacon =
beacon_monitor_fixture(org.id, user.id, %{
name: "Original",
target_url: "https://original.com"
})
assert {:ok, updated} = Beacons.update_beacon(beacon, %{name: "Renamed"})
assert updated.name == "Renamed"
assert updated.target_url == "https://original.com"
end
end
describe "delete_beacon/1" do
test "deletes beacon" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
assert {:ok, %BeaconMonitor{}} = Beacons.delete_beacon(beacon)
assert Beacons.get_beacon(beacon.id) == nil
end
end
describe "toggle_beacon/1" do
test "disables enabled beacon" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id, %{enabled: true})
assert {:ok, %BeaconMonitor{enabled: false}} = Beacons.toggle_beacon(beacon)
end
test "enables disabled beacon" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id, %{enabled: false})
assert {:ok, %BeaconMonitor{enabled: true}} = Beacons.toggle_beacon(beacon)
end
test "toggles idempotently back and forth" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id, %{enabled: true})
assert {:ok, %BeaconMonitor{enabled: false}} = Beacons.toggle_beacon(beacon)
# Toggle again — re-fetch to get updated struct
beacon = Beacons.get_beacon!(beacon.id)
assert {:ok, %BeaconMonitor{enabled: true}} = Beacons.toggle_beacon(beacon)
end
end
describe "record_check_result/3" do
test "updates status and counters for up status" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
{:ok, updated} = Beacons.record_check_result(beacon, "up", 150.5)
assert updated.last_status == "up"
assert updated.last_response_time_ms == 150.5
assert updated.checks_count == 1
assert updated.failures_count == 0
assert updated.last_checked_at
end
test "increments failures for down status" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
{:ok, updated} = Beacons.record_check_result(beacon, "down", 0.0)
assert updated.last_status == "down"
assert updated.failures_count == 1
assert updated.checks_count == 1
end
test "increments failures for timeout status" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
{:ok, updated} = Beacons.record_check_result(beacon, "timeout", 5000.0)
assert updated.last_status == "timeout"
assert updated.failures_count == 1
assert updated.checks_count == 1
end
test "increments failures for error status" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
{:ok, updated} = Beacons.record_check_result(beacon, "error", 0.0)
assert updated.last_status == "error"
assert updated.failures_count == 1
assert updated.checks_count == 1
end
test "accumulates checks and failures over multiple calls" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
{:ok, beacon} = Beacons.record_check_result(beacon, "up", 10.0)
assert beacon.checks_count == 1
assert beacon.failures_count == 0
{:ok, beacon} = Beacons.record_check_result(beacon, "down", 0.0)
assert beacon.checks_count == 2
assert beacon.failures_count == 1
{:ok, beacon} = Beacons.record_check_result(beacon, "up", 15.0)
assert beacon.checks_count == 3
assert beacon.failures_count == 1
{:ok, beacon} = Beacons.record_check_result(beacon, "down", 0.0)
assert beacon.checks_count == 4
assert beacon.failures_count == 2
end
test "updates last_checked_at timestamp" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
assert beacon.last_checked_at == nil
{:ok, updated} = Beacons.record_check_result(beacon, "up", 10.0)
assert updated.last_checked_at
end
test "accepts zero response time" do
user = user_fixture()
org = organization_fixture(user.id)
beacon = beacon_monitor_fixture(org.id, user.id)
{:ok, updated} = Beacons.record_check_result(beacon, "up", 0.0)
assert updated.last_response_time_ms == 0.0
end
end
end

View file

@ -1,284 +0,0 @@
defmodule ToweropsWeb.BeaconMonitorLiveTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
import Towerops.BeaconsFixtures
alias Towerops.Beacons
setup :register_and_log_in_user
setup %{user: user} do
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
%{organization: organization}
end
describe "Index" do
test "lists user's beacon monitors", %{conn: conn, organization: org, user: user} do
_my_beacon = beacon_monitor_fixture(org.id, user.id, %{name: "My HTTP Monitor"})
{:ok, _view, html} = live(conn, ~p"/beacons")
assert html =~ "My HTTP Monitor"
end
test "shows empty state when no beacons", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/beacons")
assert html =~ "No beacon monitors"
end
test "does not show other user's beacons", %{conn: conn, organization: org} do
other_user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
_theirs = beacon_monitor_fixture(org.id, other_user.id, %{name: "Not Mine"})
{:ok, _view, html} = live(conn, ~p"/beacons")
refute html =~ "Not Mine"
end
test "requires authentication" do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/beacons")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Create beacon" do
test "creates a new beacon monitor via modal", %{conn: conn, organization: org, user: user} do
{:ok, view, _html} = live(conn, ~p"/beacons/new")
view
|> form("#beacon-form-new",
beacon_monitor: %{
name: "New HTTP Check",
check_type: "http",
target_url: "https://example.com",
interval_seconds: "30",
timeout_ms: "3000"
}
)
|> render_submit()
assert_redirect(view, ~p"/beacons")
# Verify the beacon was created in the database
beacons = Beacons.list_user_beacons(org.id, user.id)
assert Enum.any?(beacons, &(&1.name == "New HTTP Check" and &1.target_url == "https://example.com"))
end
test "validates required fields on submit", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/beacons/new")
html =
view
|> form("#beacon-form-new",
beacon_monitor: %{
name: "",
check_type: "http",
target_url: "",
interval_seconds: "",
timeout_ms: ""
}
)
|> render_submit()
assert html =~ "can&#39;t be blank"
end
test "open_edit navigates to edit route", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{name: "Edit Me"})
{:ok, view, _html} = live(conn, ~p"/beacons")
view
|> element("#edit-beacon-#{beacon.id}")
|> render_click()
assert_redirect(view, ~p"/beacons/#{beacon.id}/edit")
end
end
describe "Edit beacon" do
test "loads edit modal with existing beacon data", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{name: "Original Name"})
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon.id}/edit")
assert html =~ "Edit Beacon Monitor"
assert html =~ "Original Name"
end
test "updates beacon name and redirects", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{name: "Old Name"})
{:ok, view, _html} = live(conn, ~p"/beacons/#{beacon.id}/edit")
view
|> form("#beacon-form-edit",
beacon_monitor: %{
name: "Updated Name",
check_type: "http",
target_url: beacon.target_url,
interval_seconds: to_string(beacon.interval_seconds),
timeout_ms: to_string(beacon.timeout_ms)
}
)
|> render_submit()
assert_redirect(view, ~p"/beacons")
updated = Beacons.get_beacon(beacon.id)
assert updated.name == "Updated Name"
end
end
describe "Toggle beacon" do
test "disables an enabled beacon", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{enabled: true})
{:ok, view, _html} = live(conn, ~p"/beacons")
view
|> element("#toggle-beacon-#{beacon.id}")
|> render_click()
refute Beacons.get_beacon(beacon.id).enabled
end
test "enables a disabled beacon", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{enabled: false})
{:ok, view, _html} = live(conn, ~p"/beacons")
view
|> element("#toggle-beacon-#{beacon.id}")
|> render_click()
assert Beacons.get_beacon(beacon.id).enabled
end
end
describe "Delete beacon" do
test "deletes a beacon", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{name: "To Delete"})
{:ok, view, _html} = live(conn, ~p"/beacons")
view
|> element("#delete-beacon-#{beacon.id}")
|> render_click()
html = render(view)
assert html =~ "Beacon deleted successfully"
refute Beacons.get_beacon(beacon.id)
end
end
describe "Timer cleanup" do
test "cleans up timer on LiveView terminate", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/beacons")
assert render(view) =~ "Beacon Monitors"
stop_live(view)
end
test "handles nil timer ref in terminate gracefully", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/beacons")
stop_live(view)
end
defp stop_live(view) do
Process.exit(view.pid, :kill)
ref = Process.monitor(view.pid)
assert_receive {:DOWN, ^ref, :process, _, _}, 1000
end
end
describe "Admin index" do
setup do
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
user = user |> Ecto.Changeset.change(%{is_superuser: true}) |> Towerops.Repo.update!()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
token = Towerops.Accounts.generate_user_session_token(user)
conn =
build_conn()
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:user_token, token)
%{conn: conn, user: user, organization: organization}
end
test "lists all beacon monitors across orgs", %{conn: conn, user: user, organization: org} do
other_user = Towerops.AccountsFixtures.user_fixture(enable_totp: false)
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org B"}, other_user.id, bypass_limits: true)
_b1 = beacon_monitor_fixture(org.id, user.id, %{name: "Beacon A"})
_b2 = beacon_monitor_fixture(org2.id, other_user.id, %{name: "Beacon B"})
{:ok, _view, html} = live(conn, ~p"/admin/beacons")
assert html =~ "Beacon A"
assert html =~ "Beacon B"
end
test "redirects non-superuser", %{user: user} do
regular_user =
user
|> Ecto.Changeset.change(%{is_superuser: false})
|> Towerops.Repo.update!()
token = Towerops.Accounts.generate_user_session_token(regular_user)
conn =
build_conn()
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:user_token, token)
assert {:error, {:redirect, %{to: "/orgs"}}} = live(conn, ~p"/admin/beacons")
end
test "requires authentication for admin" do
conn = build_conn()
assert {:error, {:redirect, %{to: "/users/log-in"}}} = live(conn, ~p"/admin/beacons")
end
test "admin can toggle beacon", %{conn: conn, user: user, organization: org} do
beacon = beacon_monitor_fixture(org.id, user.id, %{enabled: true, name: "Admin Toggle"})
{:ok, view, _html} = live(conn, ~p"/admin/beacons")
view
|> element("#admin-toggle-beacon-#{beacon.id}")
|> render_click()
refute Beacons.get_beacon(beacon.id).enabled
end
test "admin can delete beacon", %{conn: conn, user: user, organization: org} do
beacon = beacon_monitor_fixture(org.id, user.id, %{name: "Admin Delete"})
{:ok, view, _html} = live(conn, ~p"/admin/beacons")
view
|> element("#admin-delete-beacon-#{beacon.id}")
|> render_click()
refute Beacons.get_beacon(beacon.id)
end
test "shows empty state in admin when no beacons", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/beacons")
assert html =~ "No beacon monitors"
end
end
end