towerops/lib/towerops_web/live/device_live/index.ex
Graham McIntire 77cf289058
Add real-time device list with PubSub and LiveView streams
Broadcast org-scoped PubSub events from Devices context mutations
(create, update, delete, status change). Convert DeviceLive.Index
from assigns to streams with flat row items. Subscribe on mount so
the device list updates automatically across browser tabs.
2026-02-10 17:38:58 -06:00

328 lines
11 KiB
Elixir

defmodule ToweropsWeb.DeviceLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Devices
alias Towerops.Organizations.SubscriptionLimits
alias Towerops.Sites
alias Towerops.Snmp
alias Towerops.Workers.DiscoveryWorker
alias ToweropsWeb.Live.Helpers.AccessControl
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
sites = Sites.list_organization_sites(organization.id)
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
end
# Load device quota
{current, limit} = SubscriptionLimits.device_quota(organization)
{:ok,
socket
|> assign(:page_title, t_equipment("Devices"))
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices))
|> assign(:sites_enabled, organization.use_sites)
|> assign(:has_sites, sites != [])
|> assign(:reorder_mode, false)
|> assign(:device_quota, %{current: current, limit: limit})}
end
@impl true
def handle_params(params, _url, socket) do
tab = Map.get(params, "tab", "existing")
{:noreply, apply_action(socket, socket.assigns.live_action, params, tab)}
end
defp apply_action(socket, :index, params, tab) do
organization = socket.assigns.current_scope.organization
case tab do
"discovered" ->
page = params |> Map.get("page", "1") |> String.to_integer()
per_page = 20
# Load all discovered devices - this is temporarily kept in memory for slicing
# For very large datasets, consider database-level pagination
all_discovered = Snmp.list_discovered_devices_for_organization(organization.id)
total_count = length(all_discovered)
total_pages = ceil(total_count / per_page)
# Ensure page is within valid range
page = max(1, min(page, max(1, total_pages)))
offset = (page - 1) * per_page
# Only keep the current page in assigns - don't store all_discovered
discovered_devices = Enum.slice(all_discovered, offset, per_page)
socket
|> assign(:active_tab, "discovered")
# Use temporary assign - will be cleared after render
|> assign_new(:discovered_devices, fn -> discovered_devices end)
|> assign(:pagination, %{
page: page,
per_page: per_page,
total_count: total_count,
total_pages: total_pages
})
_ ->
socket
|> assign(:active_tab, "existing")
|> assign(:discovered_devices, [])
|> assign(:pagination, nil)
end
end
@impl true
def handle_event("force_rediscover_all", _params, socket) do
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
snmp_devices = Enum.filter(devices, & &1.snmp_enabled)
if Enum.empty?(snmp_devices) do
{:noreply, put_flash(socket, :error, t_equipment("No SNMP-enabled devices found"))}
else
Enum.each(snmp_devices, fn device ->
enqueue_discovery(device.id)
end)
count = length(snmp_devices)
message = t_equipment("Discovery started for %{count} device", count: count) <> if count == 1, do: "", else: "s"
{:noreply, put_flash(socket, :info, message)}
end
end
@impl true
def handle_event("reorder_site", %{"site_id" => site_id, "new_position" => new_position}, socket) do
organization = socket.assigns.current_scope.organization
# Verify site belongs to current organization
case AccessControl.verify_site_access(site_id, organization.id) do
{:ok, _site} ->
perform_site_reorder(socket, site_id, new_position, organization.id)
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, t_equipment("Site not found"))}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, t_equipment("You don't have access to this site"))}
end
end
@impl true
def handle_event("reorder_device", %{"device_id" => device_id, "new_position" => new_position}, socket) do
organization = socket.assigns.current_scope.organization
# Verify device belongs to current organization
case AccessControl.verify_device_access(device_id, organization.id) do
{:ok, _device} ->
perform_device_reorder(socket, device_id, new_position, organization.id)
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, t_equipment("Device not found"))}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, t_equipment("You don't have access to this device"))}
end
end
@impl true
def handle_event("toggle_reorder_mode", _params, socket) do
{:noreply, assign(socket, :reorder_mode, !socket.assigns.reorder_mode)}
end
@impl true
def handle_event("reset_order", _params, socket) do
organization_id = socket.assigns.current_scope.organization.id
Sites.reset_site_order(organization_id)
Devices.reset_organization_device_order(organization_id)
# Reload devices with alphabetical order
devices = Devices.list_organization_devices(organization_id)
{:noreply,
socket
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> put_flash(:info, t_equipment("Order reset to alphabetical"))}
end
def handle_event("add_discovered_device", params, socket) do
identifier = Jason.decode!(params["identifier"])
discovered = Enum.find(socket.assigns.discovered_devices, &(&1.identifier == identifier))
prefill_params = %{
"name" => discovered.hostname || "",
"ip_address" => List.first(discovered.ip_addresses) || "",
"snmp_enabled" => "true"
}
{:noreply, push_navigate(socket, to: ~p"/devices/new?#{prefill_params}")}
end
defp perform_site_reorder(socket, site_id, new_position, organization_id) do
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
result = Sites.reorder_site(site_id, position)
perform_reorder(socket, result, "site", organization_id)
end
defp perform_device_reorder(socket, device_id, new_position, organization_id) do
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
result = Devices.reorder_device(device_id, position)
perform_reorder(socket, result, "device", organization_id)
end
defp perform_reorder(socket, reorder_result, resource_name, organization_id) do
case reorder_result do
{:ok, _} ->
# Reload devices with updated order
devices = Devices.list_organization_devices(organization_id)
success_message =
case resource_name do
"site" -> t_equipment("Site order updated")
"device" -> t_equipment("Device order updated")
end
{:noreply,
socket
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> put_flash(:info, success_message)}
{:error, _changeset} ->
error_message =
case resource_name do
"site" -> t_equipment("Failed to reorder site")
"device" -> t_equipment("Failed to reorder device")
end
{:noreply, put_flash(socket, :error, error_message)}
end
end
@impl true
def handle_info({event, _org_id}, socket)
when event in [:device_created, :device_updated, :device_deleted, :device_status_changed] do
{:noreply, reload_devices(socket)}
end
defp reload_devices(socket) do
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
{current, limit} = SubscriptionLimits.device_quota(organization)
socket
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:has_devices, devices != [])
|> assign(:device_quota, %{current: current, limit: limit})
end
defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
else
DiscoveryWorker.enqueue(device_id)
end
end
defp build_device_rows(devices) do
devices
|> group_devices_by_site()
|> Enum.flat_map(fn {site, site_devices, stats} ->
site_id = if site, do: site.id, else: "no-site"
site_row = %{
id: "site-header-#{site_id}",
type: :site_header,
site: site,
stats: stats,
devices: site_devices
}
device_rows =
Enum.with_index(site_devices, fn device, idx ->
%{
id: "device-#{device.id}",
type: :device,
device: device,
site: site,
device_index: idx
}
end)
[site_row | device_rows]
end)
end
defp group_devices_by_site(devices) do
devices
|> Enum.group_by(& &1.site)
|> Enum.map(fn {site, site_devices} ->
{site, site_devices, calculate_site_stats(site_devices)}
end)
|> Enum.sort_by(
fn {site, _devices, _stats} ->
if site do
{site.display_order || 999_999, site.name}
else
{0, ""}
end
end,
:asc
)
end
defp calculate_site_stats(devices) do
status_counts = Enum.frequencies_by(devices, & &1.status)
%{
total: length(devices),
up: Map.get(status_counts, :up, 0),
down: Map.get(status_counts, :down, 0),
unknown: Map.get(status_counts, :unknown, 0)
}
end
defp device_type_icon(:router), do: "hero-signal"
defp device_type_icon(:switch), do: "hero-squares-2x2"
defp device_type_icon(:wireless), do: "hero-wifi"
defp device_type_icon(:server), do: "hero-server"
defp device_type_icon(:workstation), do: "hero-computer-desktop"
defp device_type_icon(_), do: "hero-question-mark-circle"
defp device_type_label(type) do
type |> to_string() |> String.capitalize()
end
# Generate pagination range with ellipsis for large page counts
# Shows: [1] ... [4] [5] [6] ... [20] when on page 5 of 20
defp pagination_range(current_page, total_pages) do
cond do
total_pages <= 7 ->
# Show all pages if 7 or fewer
Enum.to_list(1..total_pages)
current_page <= 4 ->
# Near the start
[1, 2, 3, 4, 5, :ellipsis, total_pages]
current_page >= total_pages - 3 ->
# Near the end
[1, :ellipsis, total_pages - 4, total_pages - 3, total_pages - 2, total_pages - 1, total_pages]
true ->
# In the middle
[1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages]
end
end
end