diff --git a/lib/towerops/beacons.ex b/lib/towerops/beacons.ex
new file mode 100644
index 00000000..e9f8e766
--- /dev/null
+++ b/lib/towerops/beacons.ex
@@ -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
diff --git a/lib/towerops/beacons/beacon_monitor.ex b/lib/towerops/beacons/beacon_monitor.ex
new file mode 100644
index 00000000..f92a2829
--- /dev/null
+++ b/lib/towerops/beacons/beacon_monitor.ex
@@ -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
diff --git a/lib/towerops/beacons/beacon_monitor_query.ex b/lib/towerops/beacons/beacon_monitor_query.ex
new file mode 100644
index 00000000..b1a7f3bf
--- /dev/null
+++ b/lib/towerops/beacons/beacon_monitor_query.ex
@@ -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
diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex
index 2786a1eb..a14516f5 100644
--- a/lib/towerops/snmp/discovery.ex
+++ b/lib/towerops/snmp/discovery.ex
@@ -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
diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex
index 8f608689..eaa523c2 100644
--- a/lib/towerops_web/components/layouts.ex
+++ b/lib/towerops_web/components/layouts.ex
@@ -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 navigate={~p"/beacons"} active={@active_page == "beacons"}>
+ {t("Beacon Monitors")}
+
<%!-- <.mobile_nav_link navigate={~p"/weathermap"} active={@active_page == "weathermap"}>
<.icon name="hero-signal" class="size-5" /> {t("Weathermap")}
--%>
@@ -930,6 +939,12 @@ defmodule ToweropsWeb.Layouts do
>
Exceptions
+ <.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")}
+
@@ -959,6 +974,9 @@ defmodule ToweropsWeb.Layouts do
<.mobile_nav_link navigate={~p"/admin/errors"} active={false}>
Exceptions
+ <.mobile_nav_link navigate={~p"/admin/beacons"} active={false}>
+ {t("Beacon Monitors")}
+
diff --git a/lib/towerops_web/live/admin/beacon_monitor_live/index.ex b/lib/towerops_web/live/admin/beacon_monitor_live/index.ex
new file mode 100644
index 00000000..f17af46d
--- /dev/null
+++ b/lib/towerops_web/live/admin/beacon_monitor_live/index.ex
@@ -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
diff --git a/lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex b/lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex
new file mode 100644
index 00000000..37ef7cd6
--- /dev/null
+++ b/lib/towerops_web/live/admin/beacon_monitor_live/index.html.heex
@@ -0,0 +1,114 @@
+
+
+
+
{t("All Beacons")}
+
+ {t_equipment("Beacon monitors across all organizations")}
+
+
+
+ <%= if !@has_beacons do %>
+
+ <.icon name="hero-signal" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
+
+ {t("No beacon monitors")}
+
+
+ {t_equipment("No beacon monitors have been created yet.")}
+
+
+ <% else %>
+
+ <.table
+ id="admin-beacons-table"
+ rows={@streams.beacons}
+ >
+ <:col :let={{_id, beacon}} label={t_equipment("Name")}>
+ {beacon.name}
+
+
+ <:col :let={{_id, beacon}} label={t("Organization")}>
+
+ {beacon.organization.name}
+
+
+
+ <:col :let={{_id, beacon}} label={t_equipment("User")}>
+
+ {beacon.user.email}
+
+
+
+ <:col :let={{_id, beacon}} label={t_equipment("Check Type")}>
+
+ {check_type_label(beacon.check_type)}
+
+
+
+ <:col :let={{_id, beacon}} label={t_equipment("Target URL")}>
+
+ {beacon.target_url}
+
+
+
+ <:col :let={{_id, beacon}} label={t_equipment("Last Status")}>
+
+
+ {status_label(beacon.last_status)}
+
+
+
+ <:col :let={{_id, beacon}} label={t("Enabled")}>
+
+
+
+ <: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 %>
+ {t_equipment("Never")}
+ <% end %>
+
+
+ <: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")}
+
+
+
+
+ <% end %>
+
+
diff --git a/lib/towerops_web/live/beacon_monitor_live/form_component.ex b/lib/towerops_web/live/beacon_monitor_live/form_component.ex
new file mode 100644
index 00000000..ffc49756
--- /dev/null
+++ b/lib/towerops_web/live/beacon_monitor_live/form_component.ex
@@ -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"""
+
+
+
+
+
+
+ <.form
+ for={@form}
+ id={"beacon-form-#{@action}"}
+ phx-target={@myself}
+ phx-change="validate"
+ phx-submit="save"
+ >
+
+
+
+
+ {title_for_action(@action)}
+
+
+ {subtitle_for_action(@action)}
+
+
+
+
+
+
+ <.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"
+ />
+
+
+ <.input
+ field={@form[:interval_seconds]}
+ type="number"
+ label={t_equipment("Interval (seconds)")}
+ />
+ <.input
+ field={@form[:timeout_ms]}
+ type="number"
+ label={t_equipment("Timeout (ms)")}
+ />
+
+
+
+
+
+
+ <.button type="submit" variant="primary" phx-disable-with={t_equipment("Saving...")}>
+ {submit_label(@action)}
+
+
+
+
+
+
+ """
+ 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
diff --git a/lib/towerops_web/live/beacon_monitor_live/helpers.ex b/lib/towerops_web/live/beacon_monitor_live/helpers.ex
new file mode 100644
index 00000000..bcb77ffd
--- /dev/null
+++ b/lib/towerops_web/live/beacon_monitor_live/helpers.ex
@@ -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
diff --git a/lib/towerops_web/live/beacon_monitor_live/index.ex b/lib/towerops_web/live/beacon_monitor_live/index.ex
new file mode 100644
index 00000000..b9d2bf86
--- /dev/null
+++ b/lib/towerops_web/live/beacon_monitor_live/index.ex
@@ -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
diff --git a/lib/towerops_web/live/beacon_monitor_live/index.html.heex b/lib/towerops_web/live/beacon_monitor_live/index.html.heex
new file mode 100644
index 00000000..6feda721
--- /dev/null
+++ b/lib/towerops_web/live/beacon_monitor_live/index.html.heex
@@ -0,0 +1,150 @@
+
+ <.header>
+ {@page_title}
+ <:subtitle>{t_equipment("Monitor your endpoints from distributed locations")}
+ <:actions>
+ <.button phx-click="open_new" variant="primary">
+ <.icon name="hero-plus" class="h-5 w-5" /> {t("Add Beacon Monitor")}
+
+
+
+
+ <%= if !@has_beacons do %>
+
+ <.icon name="hero-signal" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
+
+ {t("No beacon monitors")}
+
+
+ {t_equipment("Get started by creating your first beacon monitor.")}
+
+
+ <.button phx-click="open_new" variant="primary">
+ <.icon name="hero-plus" class="h-5 w-5" /> {t("Add Beacon Monitor")}
+
+
+
+ <% else %>
+
+ <.table
+ id="beacons-table"
+ rows={@streams.beacons}
+ >
+ <:col :let={{_id, beacon}} label={t_equipment("Name")}>
+
{beacon.name}
+
+
+ <:col :let={{_id, beacon}} label={t_equipment("Check Type")}>
+
+ {check_type_label(beacon.check_type)}
+
+
+
+ <:col :let={{_id, beacon}} label={t_equipment("Target URL")}>
+
+ {beacon.target_url}
+
+ <%= if beacon.check_type in ~w(tcp icmp) && beacon.target_port do %>
+
:{beacon.target_port}
+ <% end %>
+
+
+ <:col :let={{_id, beacon}} label={t_equipment("Last Status")}>
+
+
+ {status_label(beacon.last_status)}
+
+
+
+ <:col :let={{_id, beacon}} label={t_equipment("Response Time")}>
+ <%= if beacon.last_response_time_ms do %>
+
+ {format_response_time(beacon.last_response_time_ms)}
+
+ <% else %>
+
—
+ <% end %>
+
+
+ <: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 %>
+
{t_equipment("Never")}
+ <% end %>
+
+
+ <:col :let={{_id, beacon}} label={t("Enabled")}>
+
+
+
+ <:action :let={{_id, beacon}}>
+
+ <.button
+ id={"edit-beacon-#{beacon.id}"}
+ type="button"
+ phx-click="open_edit"
+ phx-value-id={beacon.id}
+ variant="secondary"
+ >
+ {t("Edit")}
+
+ <.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")}
+
+
+
+
+
+ <% end %>
+
+
+ <%= 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 %>
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index 73c7bf27..de7eb688 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -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
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 96641136..950e6e0f 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -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"
diff --git a/priv/gettext/en/LC_MESSAGES/equipment.po b/priv/gettext/en/LC_MESSAGES/equipment.po
index 57b1f51c..ac5f7cb0 100644
--- a/priv/gettext/en/LC_MESSAGES/equipment.po
+++ b/priv/gettext/en/LC_MESSAGES/equipment.po
@@ -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"
diff --git a/priv/repo/migrations/20260722124424_create_beacon_monitors.exs b/priv/repo/migrations/20260722124424_create_beacon_monitors.exs
new file mode 100644
index 00000000..f0a95ee7
--- /dev/null
+++ b/priv/repo/migrations/20260722124424_create_beacon_monitors.exs
@@ -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
diff --git a/test/support/fixtures/beacons_fixtures.ex b/test/support/fixtures/beacons_fixtures.ex
new file mode 100644
index 00000000..37111649
--- /dev/null
+++ b/test/support/fixtures/beacons_fixtures.ex
@@ -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
diff --git a/test/towerops/beacons_test.exs b/test/towerops/beacons_test.exs
new file mode 100644
index 00000000..cd3e63e9
--- /dev/null
+++ b/test/towerops/beacons_test.exs
@@ -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
diff --git a/test/towerops_web/live/beacon_monitor_live_test.exs b/test/towerops_web/live/beacon_monitor_live_test.exs
new file mode 100644
index 00000000..baa923dc
--- /dev/null
+++ b/test/towerops_web/live/beacon_monitor_live_test.exs
@@ -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