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.
328 lines
11 KiB
Elixir
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
|