towerops/lib/towerops_web/live/device_live/index.ex
Graham McIntire aa1bac0d46
perf: batch inserts, selective LiveView reloading, and database indexes for scale
Replace individual Repo.insert() with Repo.insert_all() for sensor readings,
interface stats, processor readings, and storage readings in agent channel and
device poller worker. Add partial/composite database indexes for common query
patterns. Dashboard now uses GROUP BY aggregation instead of loading all devices.
DeviceLive.Show only reloads data for the active tab on PubSub events.
DeviceLive.Index debounces status changes and skips quota queries on updates.
Agent polling preloads filtered to monitored sensors/interfaces only.
2026-02-12 10:16:10 -06:00

375 lines
12 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})
|> assign(:pending_reload_ref, nil)}
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
# Structural changes (create/delete) need full reload with quota recalculation
@impl true
def handle_info({event, _org_id}, socket) when event in [:device_created, :device_deleted] do
{:noreply, reload_devices(socket)}
end
# Status changes and updates are debounced to avoid redundant reloads
# when many devices change status simultaneously (e.g., network event)
@impl true
def handle_info({event, _org_id}, socket) when event in [:device_status_changed, :device_updated] do
{:noreply, schedule_debounced_reload(socket, event)}
end
@impl true
def handle_info(:debounced_device_reload, socket) do
{:noreply, reload_device_stream(socket)}
end
@debounce_ms if Mix.env() == :test, do: 0, else: 100
defp schedule_debounced_reload(socket, event) do
# Cancel any pending debounced reload
if ref = socket.assigns.pending_reload_ref do
Process.cancel_timer(ref)
end
# For status changes, skip reload entirely if viewing the discovered tab
if event == :device_status_changed && socket.assigns.active_tab == "discovered" do
assign(socket, :pending_reload_ref, nil)
else
if @debounce_ms == 0 do
# In test: reload immediately so render(view) sees the update
reload_device_stream(socket)
else
ref = Process.send_after(self(), :debounced_device_reload, @debounce_ms)
assign(socket, :pending_reload_ref, ref)
end
end
end
# Full reload: stream + quota (for structural changes like create/delete)
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
# Lightweight reload: stream only, skip quota query (for status/update changes)
defp reload_device_stream(socket) do
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
socket
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:has_devices, devices != [])
|> assign(:pending_reload_ref, nil)
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