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.
375 lines
12 KiB
Elixir
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
|