towerops/lib/towerops_web/live/beacon_monitor_live/index.html.heex
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

150 lines
5.5 KiB
Text

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