towerops/lib/towerops_web/live/beacon_monitor_live/index.ex
Graham McInitre 88fae5d752 feat: add beacon monitors with user and admin management
Backend:
- Migration: beacon_monitors table (binary_id PK, org/user FK, check_type, target_url, config, monitoring fields)
- Schema: BeaconMonitor with changeset (pattern-matched port validation, check_type inclusion)
- Query: BeaconMonitorQuery — composable scopes (by org, by user, by type, enabled, ordered, preloaded)
- Context: Towerops.Beacons — CRUD, toggle, record_check_result (36/36 tests pass)

Frontend:
- User LiveView: /beacons — stream-based list, create/edit modals, toggle/delete actions
- Admin LiveView: /admin/beacons — cross-org view with preloads (20/20 tests pass)
- Form component: LiveComponent modal with validation
- Helpers: check_type badges, status indicators, response time formatting
- Router: user routes + admin route configured
- Nav: sidebar + mobile links added
- 30+ gettext translations

Verification: compile ✓, format ✓, credo ✓, 56/56 new tests pass
2026-07-22 08:15:03 -05:00

180 lines
4.8 KiB
Elixir

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