feat: add beacon monitors with user and admin management
Backend: - Migration: beacon_monitors table (binary_id PK, org/user FK, check_type, target_url, config, monitoring fields) - Schema: BeaconMonitor with changeset (pattern-matched port validation, check_type inclusion) - Query: BeaconMonitorQuery — composable scopes (by org, by user, by type, enabled, ordered, preloaded) - Context: Towerops.Beacons — CRUD, toggle, record_check_result (36/36 tests pass) Frontend: - User LiveView: /beacons — stream-based list, create/edit modals, toggle/delete actions - Admin LiveView: /admin/beacons — cross-org view with preloads (20/20 tests pass) - Form component: LiveComponent modal with validation - Helpers: check_type badges, status indicators, response time formatting - Router: user routes + admin route configured - Nav: sidebar + mobile links added - 30+ gettext translations Verification: compile ✓, format ✓, credo ✓, 56/56 new tests pass
This commit is contained in:
parent
5b91735b14
commit
88fae5d752
18 changed files with 2257 additions and 4 deletions
145
lib/towerops/beacons.ex
Normal file
145
lib/towerops/beacons.ex
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
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
|
||||
108
lib/towerops/beacons/beacon_monitor.ex
Normal file
108
lib/towerops/beacons/beacon_monitor.ex
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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
|
||||
54
lib/towerops/beacons/beacon_monitor_query.ex
Normal file
54
lib/towerops/beacons/beacon_monitor_query.ex
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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
|
||||
|
|
@ -229,6 +229,7 @@ defmodule Towerops.Snmp.Discovery do
|
|||
{:ok, transceivers} = with_timeout(client_opts, fn -> Base.discover_transceivers(client_opts) end, timeouts)
|
||||
|
||||
Logger.info("Discovering printer supplies...", device_id: device.id)
|
||||
|
||||
{:ok, printer_supplies} =
|
||||
with_timeout(client_opts, fn -> Base.discover_printer_supplies(client_opts) end, timeouts)
|
||||
|
||||
|
|
@ -316,7 +317,8 @@ defmodule Towerops.Snmp.Discovery do
|
|||
timeout: timeouts[:slow],
|
||||
default: {:error, :interface_discovery_timeout}
|
||||
) do
|
||||
{:ok, _ifaces} = ok -> ok
|
||||
{:ok, _ifaces} = ok ->
|
||||
ok
|
||||
|
||||
{:error, :interface_discovery_timeout} ->
|
||||
Logger.error("Interface discovery timed out - aborting to prevent data loss", device_id: device_id)
|
||||
|
|
@ -334,7 +336,8 @@ defmodule Towerops.Snmp.Discovery do
|
|||
timeout: timeouts[:slow],
|
||||
default: {:error, :sensor_discovery_timeout}
|
||||
) do
|
||||
{:ok, _sensors} = ok -> ok
|
||||
{:ok, _sensors} = ok ->
|
||||
ok
|
||||
|
||||
{:error, :sensor_discovery_timeout} ->
|
||||
Logger.error("Sensor discovery timed out - aborting to prevent data loss", device_id: device_id)
|
||||
|
|
@ -1604,8 +1607,7 @@ defmodule Towerops.Snmp.Discovery do
|
|||
|
||||
@spec update_device_name_from_snmp(DeviceSchema.t(), system_info()) ::
|
||||
{: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)
|
||||
{:ok, device}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -378,6 +378,12 @@ defmodule ToweropsWeb.Layouts do
|
|||
icon="hero-signal"
|
||||
label={t("Coverage")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/beacons"}
|
||||
active={@active_page == "beacons"}
|
||||
icon="hero-radio"
|
||||
label={t("Beacon Monitors")}
|
||||
/>
|
||||
<%!-- <.sidebar_link
|
||||
navigate={~p"/weathermap"}
|
||||
active={@active_page == "weathermap"}
|
||||
|
|
@ -589,6 +595,9 @@ defmodule ToweropsWeb.Layouts do
|
|||
<.mobile_nav_link navigate={~p"/network-map"} active={@active_page == "network-map"}>
|
||||
{t("Network Map")}
|
||||
</.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"}>
|
||||
<.icon name="hero-signal" class="size-5" /> {t("Weathermap")}
|
||||
</.mobile_nav_link> --%>
|
||||
|
|
@ -930,6 +939,12 @@ defmodule ToweropsWeb.Layouts do
|
|||
>
|
||||
Exceptions
|
||||
</.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 class="flex items-center gap-1 sm:gap-2">
|
||||
|
|
@ -959,6 +974,9 @@ defmodule ToweropsWeb.Layouts do
|
|||
<.mobile_nav_link navigate={~p"/admin/errors"} active={false}>
|
||||
Exceptions
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/admin/beacons"} active={false}>
|
||||
{t("Beacon Monitors")}
|
||||
</.mobile_nav_link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
115
lib/towerops_web/live/admin/beacon_monitor_live/index.ex
Normal file
115
lib/towerops_web/live/admin/beacon_monitor_live/index.ex
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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
|
||||
114
lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex
Normal file
114
lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<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>
|
||||
235
lib/towerops_web/live/beacon_monitor_live/form_component.ex
Normal file
235
lib/towerops_web/live/beacon_monitor_live/form_component.ex
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
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
|
||||
86
lib/towerops_web/live/beacon_monitor_live/helpers.ex
Normal file
86
lib/towerops_web/live/beacon_monitor_live/helpers.ex
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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
|
||||
180
lib/towerops_web/live/beacon_monitor_live/index.ex
Normal file
180
lib/towerops_web/live/beacon_monitor_live/index.ex
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
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
|
||||
150
lib/towerops_web/live/beacon_monitor_live/index.html.heex
Normal file
150
lib/towerops_web/live/beacon_monitor_live/index.html.heex
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<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">—</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>
|
||||
|
|
@ -366,6 +366,7 @@ defmodule ToweropsWeb.Router do
|
|||
live "/audit", AuditLive.Index, :index
|
||||
live "/monitoring", MonitoringLive, :index
|
||||
live "/agents", AgentLive.Index, :index
|
||||
live "/beacons", BeaconMonitorLive.Index, :index
|
||||
live "/security", SecurityLive.Index, :index
|
||||
end
|
||||
end
|
||||
|
|
@ -462,6 +463,11 @@ defmodule ToweropsWeb.Router do
|
|||
live "/agents/:id/edit", AgentLive.Edit, :edit
|
||||
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
|
||||
live "/sites", SiteLive.Index, :index
|
||||
live "/sites/new", SiteLive.Form, :new
|
||||
|
|
|
|||
|
|
@ -5455,3 +5455,26 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "— WISP Network Operator"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -318,3 +318,176 @@ msgstr "Please select exactly 2 backups to compare"
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to resolve alert"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
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
|
||||
27
test/support/fixtures/beacons_fixtures.ex
Normal file
27
test/support/fixtures/beacons_fixtures.ex
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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
|
||||
499
test/towerops/beacons_test.exs
Normal file
499
test/towerops/beacons_test.exs
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
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
|
||||
284
test/towerops_web/live/beacon_monitor_live_test.exs
Normal file
284
test/towerops_web/live/beacon_monitor_live_test.exs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
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'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
|
||||
Loading…
Add table
Reference in a new issue