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