defmodule ToweropsWeb.DeviceLive.Index do @moduledoc false use ToweropsWeb, :live_view alias Towerops.Devices alias Towerops.Gaiia alias Towerops.Monitoring 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}") schedule_tick() end # Load device quota {current, limit} = SubscriptionLimits.device_quota(organization) site_subscribers = load_site_subscribers(devices) {:ok, socket |> assign(:page_title, t_equipment("Devices")) |> assign(:timezone, socket.assigns.current_scope.timezone) |> assign(:has_devices, devices != []) |> assign(:device_rows, build_device_rows(devices)) |> assign(:sites_enabled, organization.use_sites) |> assign(:has_sites, sites != []) |> assign(:reorder_mode, false) |> assign(:total_devices, length(devices)) |> assign(:total_up, Enum.count(devices, &(&1.status == :up))) |> assign(:total_down, Enum.count(devices, &(&1.status == :down))) |> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown))) |> assign(:device_quota, %{current: current, limit: limit}) |> assign(:site_subscribers, site_subscribers) |> assign(:search_query, "") |> assign(:status_filter, "all") |> assign(:pending_reload_ref, nil) |> assign(:reload_counter, 0)} 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 = safe_to_integer(Map.get(params, "page", "1"), 1) 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 regular assign to prevent state bleeding between tab switches |> assign(:discovered_devices, discovered_devices) |> assign(:pagination, %{ page: page, per_page: per_page, total_count: total_count, total_pages: total_pages }) _ -> # Reload device list to ensure immediate rendering when switching back to existing tab. # Without this, the table won't appear until the next PubSub update or tick event. devices = Devices.list_organization_devices(organization.id) socket |> assign(:active_tab, "existing") |> assign(:discovered_devices, []) |> assign(:pagination, nil) |> assign(:device_rows, build_device_rows(devices)) |> assign(:reload_counter, (socket.assigns[:reload_counter] || 0) + 1) end end @impl true def handle_event("search", %{"search_query" => query}, socket) do {:noreply, socket |> assign(:search_query, query) |> apply_filters()} end @impl true def handle_event("filter_status", %{"status" => status}, socket) do new_status = if socket.assigns.status_filter == status, do: "all", else: status {:noreply, socket |> assign(:status_filter, new_status) |> apply_filters()} 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 != []) |> assign(:device_rows, build_device_rows(devices)) |> put_flash(:info, t_equipment("Order reset to alphabetical"))} end def handle_event("add_discovered_device", params, socket) do case Jason.decode(params["identifier"]) do {:ok, 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}")} {:error, _reason} -> {:noreply, put_flash(socket, :error, t_equipment("Invalid device identifier"))} end 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 != []) |> assign(:device_rows, build_device_rows(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 # Periodic tick to refresh "time ago" timestamps between poll events @impl true def handle_info(:tick, socket) do schedule_tick() if socket.assigns.active_tab == "existing" do {:noreply, schedule_debounced_reload(socket, :tick)} else {:noreply, socket} 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 organization = socket.assigns.current_scope.organization devices = Devices.list_organization_devices(organization.id) socket = socket |> assign(:pending_reload_ref, nil) |> assign(:device_rows, build_device_rows(devices)) {:noreply, socket} end def handle_info(_msg, socket), do: {:noreply, socket} defp tick_interval_ms do if Application.get_env(:towerops, :env) == :test, do: :infinity, else: to_timeout(second: 15) end defp schedule_tick do case tick_interval_ms() do :infinity -> :ok interval -> Process.send_after(self(), :tick, interval) end end defp debounce_ms do if Application.get_env(:towerops, :env) == :test, do: 0, else: 100 end 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 case debounce_ms() do 0 -> # In test: reload immediately so render(view) sees the update reload_device_stream(socket) ms -> ref = Process.send_after(self(), :debounced_device_reload, 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 |> assign(:device_rows, build_device_rows(devices)) |> assign(:has_devices, devices != []) |> assign(:device_quota, %{current: current, limit: limit}) |> assign(:site_subscribers, load_site_subscribers(devices)) |> assign(:total_devices, length(devices)) |> assign(:total_up, Enum.count(devices, &(&1.status == :up))) |> assign(:total_down, Enum.count(devices, &(&1.status == :down))) |> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown))) |> assign(:reload_counter, (socket.assigns[:reload_counter] || 0) + 1) 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 |> assign(:device_rows, build_device_rows(devices)) |> assign(:has_devices, devices != []) |> assign(:pending_reload_ref, nil) |> assign(:total_devices, length(devices)) |> assign(:total_up, Enum.count(devices, &(&1.status == :up))) |> assign(:total_down, Enum.count(devices, &(&1.status == :down))) |> assign(:reload_counter, (socket.assigns[:reload_counter] || 0) + 1) |> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown))) end defp apply_filters(socket) do organization = socket.assigns.current_scope.organization devices = Devices.list_organization_devices(organization.id) filtered = filter_devices(devices, socket.assigns.search_query, socket.assigns.status_filter) socket |> assign(:device_rows, build_device_rows(filtered)) |> assign(:has_devices, devices != []) end defp filter_devices(devices, query, status_filter) do devices |> filter_by_search(query) |> filter_by_status(status_filter) end defp filter_by_search(devices, ""), do: devices defp filter_by_search(devices, nil), do: devices defp filter_by_search(devices, query) do q = String.downcase(query) Enum.filter(devices, fn device -> String.contains?(String.downcase(device.name || ""), q) || String.contains?(String.downcase(to_string(device.ip_address)), q) end) end defp filter_by_status(devices, "all"), do: devices defp filter_by_status(devices, "up"), do: Enum.filter(devices, &(&1.status == :up)) defp filter_by_status(devices, "down"), do: Enum.filter(devices, &(&1.status == :down)) defp filter_by_status(devices, "unknown"), do: Enum.filter(devices, &(&1.status == :unknown)) defp filter_by_status(devices, _), do: devices defp enqueue_discovery(device_id) do if Application.get_env(:towerops, :env) == :test do _ = Task.start(fn -> run_discovery_if_exists(device_id) end) else DiscoveryWorker.enqueue(device_id) end end defp run_discovery_if_exists(device_id) do case Devices.get_device(device_id) do nil -> :ok device -> Snmp.discover_device(device) end end defp build_device_rows(devices) do device_ids = Enum.map(devices, & &1.id) health_data = Monitoring.get_device_health_summary(device_ids) response_data = Monitoring.get_device_latest_response_times(device_ids) subscriber_data = Gaiia.get_device_subscriber_counts(device_ids) 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, health: Map.get(health_data, device.id), response: Map.get(response_data, device.id), subscribers: Map.get(subscriber_data, device.id) } 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_label(nil), do: "Unknown" defp device_type_label("access_point"), do: "Access Point" defp device_type_label(role) when is_binary(role), do: String.capitalize(role) defp device_type_label(_), do: "Unknown" defp load_site_subscribers(devices) do site_ids = devices |> Enum.map(& &1.site_id) |> Enum.uniq() |> Enum.reject(&is_nil/1) Gaiia.get_site_subscriber_summaries(site_ids) end defp device_row_title(device) do {last_seen, _color} = last_seen_text(device.last_checked_at) snmp = if device.snmp_enabled, do: "SNMP: #{device.snmp_version || "v2c"}", else: "SNMP: off" type_label = device_type_label(device.device_role) "#{type_label} | Last Poll: #{last_seen} | #{snmp}" end # Health indicator helpers defp health_dot_color(nil), do: "bg-gray-400" defp health_dot_color(%{worst_status: 0}), do: "bg-green-500" defp health_dot_color(%{worst_status: 1}), do: "bg-yellow-500" defp health_dot_color(%{worst_status: 2}), do: "bg-red-500" defp health_dot_color(%{worst_status: 3}), do: "bg-gray-400" defp health_dot_color(_), do: "bg-gray-400" defp health_dot_title(nil), do: "No check data" defp health_dot_title(%{worst_status: 0}), do: "All checks passing" defp health_dot_title(%{worst_status: 1}), do: "Warning" defp health_dot_title(%{worst_status: 2}), do: "Critical" defp health_dot_title(%{worst_status: 3}), do: "Unknown" defp health_dot_title(_), do: "No check data" defp last_seen_text(nil), do: {"Never", "text-gray-400 dark:text-gray-600"} defp last_seen_text(datetime) do diff_seconds = DateTime.diff(DateTime.utc_now(), datetime, :second) cond do diff_seconds < 0 -> {"just now", "text-green-600 dark:text-green-400"} diff_seconds < 60 -> {"#{diff_seconds}s ago", "text-green-600 dark:text-green-400"} diff_seconds < 600 -> {"#{div(diff_seconds, 60)}m ago", "text-green-600 dark:text-green-400"} diff_seconds < 3600 -> {"#{div(diff_seconds, 60)}m ago", "text-yellow-600 dark:text-yellow-400"} diff_seconds < 86_400 -> {"#{div(diff_seconds, 3600)}h ago", "text-red-600 dark:text-red-400"} true -> {"#{div(diff_seconds, 86_400)}d ago", "text-red-600 dark:text-red-400"} end end defp response_time_badge(nil), do: {"-", "text-gray-400"} defp response_time_badge(%{response_time_ms: nil}), do: {"-", "text-gray-400"} defp response_time_badge(%{response_time_ms: ms}) do formatted = cond do ms < 1 -> "<1ms" ms < 1000 -> "#{round(ms)}ms" true -> "#{Float.round(ms / 1000, 1)}s" end color = cond do ms < 20 -> "text-green-600 dark:text-green-400" ms <= 100 -> "text-yellow-600 dark:text-yellow-400" true -> "text-red-600 dark:text-red-400" end {formatted, color} end defp response_time_bar_width(nil), do: 0 defp response_time_bar_width(%{response_time_ms: nil}), do: 0 defp response_time_bar_width(%{response_time_ms: ms}) do # Scale: 0-200ms maps to 0-100% width, capped at 100 min(100, round(ms / 2)) end defp response_time_bar_color(nil), do: "bg-gray-300" defp response_time_bar_color(%{response_time_ms: nil}), do: "bg-gray-300" defp response_time_bar_color(%{response_time_ms: ms}) do cond do ms < 20 -> "bg-green-500" ms <= 100 -> "bg-yellow-500" true -> "bg-red-500" end end defp format_subscriber_count(nil), do: nil defp format_subscriber_count(%{subscriber_count: count}) when count > 0 do ngettext("%{count} sub", "%{count} subs", count, count: count) end defp format_subscriber_count(_), do: nil # 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 defp safe_to_integer(value, _default) when is_integer(value), do: value defp safe_to_integer(value, default) when is_binary(value) do case Integer.parse(value) do {int, _} -> int :error -> default end end defp safe_to_integer(_, default), do: default end