towerops/lib/towerops_web/live/device_live/index.ex
Graham McIntire c70b385db1
refactor: consolidate reorder handlers in device_live
Extract common pattern from perform_site_reorder/4 and perform_device_reorder/4
into a single perform_reorder/4 helper function.

Both reorder handlers now delegate to the shared helper, reducing duplication
and making the reload/regroup/flash pattern consistent.

- Reduces code from 40 lines to 28 lines
- Maintains identical behavior (all tests pass)
- Uses static gettext strings to satisfy compile-time requirements
2026-02-04 18:02:13 -06:00

283 lines
9.5 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
device = Devices.list_organization_devices(organization.id)
sites = Sites.list_organization_sites(organization.id)
grouped_devices = group_devices_by_site(device)
# 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(:device, device)
|> assign(:grouped_devices, grouped_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
devices = socket.assigns.device
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)
grouped_devices = group_devices_by_site(devices)
{:noreply,
socket
|> assign(:device, devices)
|> assign(:grouped_devices, grouped_devices)
|> 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)
grouped_devices = group_devices_by_site(devices)
success_message =
case resource_name do
"site" -> t_equipment("Site order updated")
"device" -> t_equipment("Device order updated")
end
{:noreply,
socket
|> assign(:device, devices)
|> assign(:grouped_devices, grouped_devices)
|> 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
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
# Group devices by site with statistics
# Sites and devices are sorted by display_order (with alphabetical fallback)
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
# Put devices without sites first (display_order 0)
{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