defmodule ToweropsWeb.DeviceLive.Form do @moduledoc false use ToweropsWeb, :live_view alias Towerops.Agents alias Towerops.Devices alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Organizations.SubscriptionLimits alias Towerops.Sites alias Towerops.Snmp alias Towerops.Workers.DiscoveryWorker @impl true def mount(params, _session, socket) do organization = socket.assigns.current_organization sites = Sites.list_organization_sites(organization.id) agents = Agents.list_organization_agent_tokens(organization.id) # Include cloud pollers in the available agents list cloud_pollers = Agents.list_cloud_pollers() all_agents = agents ++ cloud_pollers # Load device quota {current_devices, device_limit} = SubscriptionLimits.device_quota(organization) is_at_limit = case device_limit do :unlimited -> false limit -> current_devices >= limit end # Redirect to sites page if no sites exist if Enum.empty?(sites) do {:ok, socket |> put_flash(:info, "Please create a site before adding a device.") |> push_navigate(to: ~p"/sites/new")} else {:ok, socket |> assign(:organization, organization) |> assign(:available_sites, sites) |> assign(:available_agents, all_agents) |> assign(:preselected_site_id, params["site_id"]) |> assign(:snmp_test_result, nil) |> assign(:duplicate_device, nil) |> assign(:non_routable_ip_error, false) |> assign(:device_quota, %{ current: current_devices, limit: device_limit, at_limit: is_at_limit })} end end @impl true def handle_params(params, _url, socket) do {:noreply, apply_action(socket, socket.assigns.live_action, params)} end defp apply_action(socket, :new, params) do equipment_attrs = %{ monitoring_enabled: true, check_interval_seconds: 300, snmp_enabled: true, snmp_version: "2c", snmp_port: 161 } # Pre-fill from query params if available (e.g., from discovered devices) equipment_attrs = equipment_attrs |> merge_param(:name, params["name"]) |> merge_param(:ip_address, params["ip_address"]) |> merge_bool_param(:snmp_enabled, params["snmp_enabled"]) equipment_attrs = cond do # Use preselected site from URL params if available socket.assigns.preselected_site_id -> Map.put(equipment_attrs, :site_id, socket.assigns.preselected_site_id) # Auto-select if there's only one site length(socket.assigns.available_sites) == 1 -> site = List.first(socket.assigns.available_sites) Map.put(equipment_attrs, :site_id, site.id) # Otherwise, leave it empty for user to select true -> equipment_attrs end changeset = Devices.change_device(%DeviceSchema{}, equipment_attrs) socket |> assign(:page_title, "New Device") |> assign(:device, %DeviceSchema{}) |> assign(:form, to_form(changeset)) |> assign(:monitoring_mode, "snmp_and_icmp") end defp apply_action(socket, :edit, %{"id" => id}) do organization = socket.assigns.current_organization case Devices.get_device(id) do nil -> socket |> put_flash(:error, "Device not found") |> push_navigate(to: ~p"/devices") device -> # Preload necessary associations for agent resolution device = Towerops.Repo.preload(device, site: [organization: :default_agent_token]) # Verify device belongs to current organization if device.site.organization_id == organization.id do apply_edit_action(socket, device) else socket |> put_flash(:error, "You don't have access to this device") |> push_navigate(to: ~p"/devices") end end end defp apply_edit_action(socket, device) do # Get current agent assignment if any current_assignment = Agents.get_device_assignment(device.id) # Get the effective agent and where it comes from {effective_agent_id, agent_source} = Agents.get_effective_agent_token_with_source(device) # For the form, we only want the device-specific assignment agent_token_id = if current_assignment do current_assignment.agent_token_id end # Get agent name for display effective_agent_name = if effective_agent_id do agent = Enum.find(socket.assigns.available_agents, &(&1.id == effective_agent_id)) agent && agent.name end # Add agent_token_id to the changeset data device_with_agent = Map.put(device, :agent_token_id, agent_token_id) changeset = Devices.change_device(device_with_agent, %{}) # Get effective SNMP configuration and source snmp_config = Devices.get_snmp_config(device) # Determine monitoring mode based on current snmp_enabled value monitoring_mode = if device.snmp_enabled, do: "snmp_and_icmp", else: "icmp_only" socket |> assign(:page_title, "Edit Device") |> assign(:device, device_with_agent) |> assign(:form, to_form(changeset)) |> assign(:agent_source, agent_source) |> assign(:effective_agent_name, effective_agent_name) |> assign(:snmp_config_source, snmp_config.source) |> assign(:effective_snmp_version, snmp_config.version) |> assign(:effective_snmp_community, snmp_config.community || "(not set)") |> assign(:monitoring_mode, monitoring_mode) end @impl true def handle_event("switch_monitoring_mode", %{"mode" => mode}, socket) do # Update snmp_enabled based on the selected mode snmp_enabled = mode == "snmp_and_icmp" # Get current form params current_params = socket.assigns.form.params # Update params with new snmp_enabled value updated_params = Map.put(current_params, "snmp_enabled", snmp_enabled) # Create new changeset with updated params changeset = socket.assigns.device |> Devices.change_device(updated_params) |> Map.put(:action, :validate) socket |> assign(:monitoring_mode, mode) |> assign(:form, to_form(changeset)) |> then(&{:noreply, &1}) end @impl true def handle_event("validate", %{"device" => device_params}, socket) do device_params = sanitize_device_params(device_params) changeset = socket.assigns.device |> Devices.change_device(device_params) |> Map.put(:action, :validate) # Check for duplicate IP address within the same site ip_address = device_params["ip_address"] site_id = device_params["site_id"] exclude_id = if socket.assigns.live_action == :edit, do: socket.assigns.device.id duplicate_device = if ip_address && String.trim(ip_address) != "" && site_id do Devices.get_device_by_ip(String.trim(ip_address), site_id, exclude_id) end # Check for non-routable IP with cloud poller non_routable_ip_error = check_non_routable_ip_cloud_error(device_params, socket.assigns) {:noreply, socket |> assign(:form, to_form(changeset)) |> assign(:duplicate_device, duplicate_device) |> assign(:non_routable_ip_error, non_routable_ip_error)} end @impl true def handle_event("save", %{"device" => device_params}, socket) do device_params = sanitize_device_params(device_params) save_device(socket, socket.assigns.live_action, device_params) end @impl true def handle_event("delete", _params, socket) do case Devices.delete_device(socket.assigns.device) do {:ok, _} -> {:noreply, socket |> put_flash(:info, "Device deleted successfully") |> push_navigate(to: ~p"/devices")} {:error, _} -> {:noreply, put_flash(socket, :error, "Unable to delete device")} end end @impl true def handle_event("test_snmp", _params, socket) do # Check if an agent is assigned agent_token_id = socket.assigns.form.params["agent_token_id"] result = if agent_token_id && agent_token_id != "" do # Agent is configured - can't test directly from web server %{ success: true, message: "SNMP configuration saved. Connection will be tested by the assigned agent during next polling cycle." } else # No agent configured - test directly from web server snmp_config = extract_snmp_config(socket.assigns.form, socket.assigns) test_snmp_connection(snmp_config) end {:noreply, assign(socket, :snmp_test_result, result)} end @impl true def handle_event("trigger_discovery", _params, socket) do device = socket.assigns.device if device.snmp_enabled do # Enqueue discovery job _ = enqueue_discovery(device.id) # Navigate to device show page where live updates will appear {:noreply, socket |> put_flash(:info, "Discovery started...") |> push_navigate(to: ~p"/devices/#{device.id}")} else {:noreply, put_flash(socket, :error, "SNMP is not enabled for this device")} end end defp save_device(socket, :new, device_params) do # Extract agent_token_id before creating device agent_token_id = Map.get(device_params, "agent_token_id") device_params = Map.delete(device_params, "agent_token_id") # Add snmp_enabled based on monitoring mode snmp_enabled = socket.assigns.monitoring_mode == "snmp_and_icmp" device_params = Map.put(device_params, "snmp_enabled", snmp_enabled) # Check if user is superuser and bypass limits if so is_superuser = socket.assigns.current_scope.user.is_superuser opts = if is_superuser, do: [bypass_limits: true], else: [] case Devices.create_device(device_params, opts) do {:ok, device} -> # Handle agent assignment after device creation handle_agent_assignment(device.id, agent_token_id) flash_message = handle_device_creation(device) # Reload quota after device creation organization = socket.assigns.organization {current, limit} = SubscriptionLimits.device_quota(organization) is_at_limit = case limit do :unlimited -> false l -> current >= l end # Reset form for next device while staying on the add page fresh_attrs = %{ monitoring_enabled: true, check_interval_seconds: 300, snmp_enabled: snmp_enabled, snmp_version: "2c", snmp_port: 161, # Keep the same site selected site_id: device.site_id } fresh_changeset = Devices.change_device(%DeviceSchema{}, fresh_attrs) {:noreply, socket |> put_flash(:info, flash_message) |> assign(:form, to_form(fresh_changeset)) |> assign(:device_quota, %{current: current, limit: limit, at_limit: is_at_limit}) |> assign(:snmp_test_result, nil) |> push_event("scroll_to_top", %{})} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} end end defp save_device(socket, :edit, device_params) do old_device = socket.assigns.device # Extract agent_token_id before updating device agent_token_id = Map.get(device_params, "agent_token_id") device_params = Map.delete(device_params, "agent_token_id") # Add snmp_enabled based on monitoring mode snmp_enabled = socket.assigns.monitoring_mode == "snmp_and_icmp" device_params = Map.put(device_params, "snmp_enabled", snmp_enabled) case Devices.update_device(old_device, device_params) do {:ok, device} -> # device update handle_agent_assignment(device.id, agent_token_id) flash_message = handle_device_update(old_device, device) {:noreply, socket |> put_flash(:info, flash_message) |> push_navigate(to: ~p"/devices/#{device.id}")} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} end end defp handle_device_creation(device) do device_label = device.name || device.ip_address base_message = if device.snmp_enabled do "Device created. SNMP discovery started. " else "Device created. " end _ = if device.snmp_enabled do enqueue_discovery(device.id) end %{ text: base_message, link_text: device_label, link_url: "/devices/#{device.id}" } end defp handle_device_update(old_device, device) do if should_trigger_snmp_discovery?(old_device, device) do _ = enqueue_discovery(device.id) "Device updated successfully. SNMP discovery started in background." else "Device updated successfully" end end # Enqueue discovery job - safe to call in test environment defp enqueue_discovery(device_id) do if Application.get_env(:towerops, :env) == :test do # In test, skip discovery (tests can call Snmp.discover_device directly if needed) :ok else # In dev/prod, enqueue to Oban DiscoveryWorker.enqueue(device_id) end end defp should_trigger_snmp_discovery?(old_device, device) do device.snmp_enabled and (!old_device.snmp_enabled or snmp_config_changed?(old_device, device)) end defp snmp_config_changed?(old_device, device) do device.snmp_community != old_device.snmp_community or device.snmp_version != old_device.snmp_version or device.snmp_port != old_device.snmp_port or device.ip_address != old_device.ip_address end @spec extract_snmp_config(Phoenix.HTML.Form.t(), map()) :: %{ ip_address: String.t() | nil, snmp_community: String.t() | nil, snmp_version: String.t() | nil, snmp_port: integer() } defp extract_snmp_config(form, assigns) do form_data = form.data params = form.params device_community = params["snmp_community"] || form_data.snmp_community # If device community is nil or empty, resolve from site/org hierarchy effective_community = if is_nil(device_community) or device_community == "" do resolve_inherited_community(params, assigns) else device_community end %{ ip_address: params["ip_address"] || form_data.ip_address, snmp_community: effective_community, snmp_version: params["snmp_version"] || form_data.snmp_version, snmp_port: normalize_port(params["snmp_port"] || form_data.snmp_port || 161) } end defp resolve_inherited_community(params, assigns) do # For edit mode, use effective SNMP community (already resolved) if has_effective_community?(assigns) do assigns.effective_snmp_community else resolve_community_from_hierarchy(params, assigns) end end defp has_effective_community?(assigns) do assigns[:effective_snmp_community] && assigns.effective_snmp_community != "(not set)" end defp resolve_community_from_hierarchy(params, assigns) do selected_site_id = get_selected_site_id(params, assigns) site_community = get_site_community(selected_site_id, assigns) site_community || get_org_community(assigns) end defp get_selected_site_id(params, assigns) do sites = assigns[:available_sites] || [] params["site_id"] || assigns[:preselected_site_id] || (length(sites) == 1 && List.first(sites).id) end defp get_site_community(nil, _assigns), do: nil defp get_site_community(site_id, assigns) do site = Enum.find(assigns.available_sites, &(&1.id == site_id)) if site && site.snmp_community && site.snmp_community != "", do: site.snmp_community end defp get_org_community(assigns) do org = assigns[:organization] org && org.snmp_community end @spec normalize_port(integer() | String.t() | any()) :: integer() defp normalize_port(port) when is_integer(port) and port >= 1 and port <= 65_535, do: port defp normalize_port(port) when is_integer(port), do: 161 defp normalize_port(port) when is_binary(port) do case Integer.parse(port) do {parsed_port, ""} when parsed_port >= 1 and parsed_port <= 65_535 -> parsed_port _ -> 161 end end defp normalize_port(_), do: 161 @spec test_snmp_connection(%{ ip_address: String.t() | nil, snmp_community: String.t() | nil, snmp_version: String.t() | nil, snmp_port: integer() }) :: %{success: boolean(), message: String.t()} defp test_snmp_connection(config) do # Validate IP address before testing case validate_test_snmp_input(config) do :ok -> case Snmp.test_connection( config.ip_address, config.snmp_community, config.snmp_version, config.snmp_port ) do {:ok, message} -> %{success: true, message: message} {:error, reason} -> %{success: false, message: "Connection failed: #{inspect(reason)}"} end {:error, message} -> %{success: false, message: message} end end @spec validate_test_snmp_input(%{ ip_address: String.t() | nil, snmp_community: String.t() | nil, snmp_version: String.t() | nil, snmp_port: integer() }) :: :ok | {:error, String.t()} defp validate_test_snmp_input(%{ip_address: nil}) do {:error, "IP address is required"} end defp validate_test_snmp_input(%{snmp_community: nil}) do {:error, "SNMP community string is required. Set one at the device, site, or organization level."} end defp validate_test_snmp_input(%{snmp_community: ""}) do {:error, "SNMP community string is required. Set one at the device, site, or organization level."} end defp validate_test_snmp_input(%{ip_address: ip}) do # Validate IP address format using Erlang's inet module case ip |> String.to_charlist() |> :inet.parse_address() do {:ok, _} -> :ok {:error, _} -> {:error, "Invalid IP address format"} end end defp handle_agent_assignment(device_id, agent_token_id) when is_binary(agent_token_id) do # Only assign if agent_token_id is not empty if agent_token_id == "" do Agents.update_device_assignment(device_id, nil) else Agents.update_device_assignment(device_id, agent_token_id) end end defp handle_agent_assignment(device_id, nil) do # No agent selected, remove any assignment Agents.update_device_assignment(device_id, nil) end defp handle_agent_assignment(_device_id, _), do: :ok # Sanitize device params - trim whitespace from IP address defp sanitize_device_params(params) do case Map.get(params, "ip_address") do ip when is_binary(ip) -> Map.put(params, "ip_address", String.trim(ip)) _ -> params end end # Check if a non-routable IP is being used with cloud poller # Skip this check in dev mode or for specific users defp check_non_routable_ip_cloud_error(device_params, assigns) do # Skip check in dev mode or for specific users if skip_non_routable_check?(assigns) do false else ip_address = device_params["ip_address"] # Only check if we have a valid IP address with true <- is_binary(ip_address) and String.trim(ip_address) != "", trimmed_ip = String.trim(ip_address), true <- non_routable_ip?(trimmed_ip), true <- using_cloud_poller?(device_params, assigns) do true else _ -> false end end end defp skip_non_routable_check?(assigns) do # Skip in dev mode or for specific users Application.get_env(:towerops, :env) == :dev || (assigns[:current_scope] && assigns.current_scope.user && assigns.current_scope.user.email == "graham@mcintire.me") end # Determine if the device would use cloud poller (no agent at any level) defp using_cloud_poller?(device_params, assigns) do # Check if agent is directly assigned in form form_agent_id = device_params["agent_token_id"] if form_agent_id && form_agent_id != "" do false else # Check if site has a default agent site_id = device_params["site_id"] site = site_id && Enum.find(assigns.available_sites, &(&1.id == site_id)) site_agent_id = site && site.agent_token_id if site_agent_id do false else # Check if organization has a default agent org = assigns.organization org_agent_id = org && org.default_agent_token_id # Cloud poller if no agent at any level is_nil(org_agent_id) end end end # Merge a query param into attrs if present and non-empty defp merge_param(attrs, _key, nil), do: attrs defp merge_param(attrs, _key, ""), do: attrs defp merge_param(attrs, key, value) when is_binary(value) do Map.put(attrs, key, value) end # Merge a boolean query param into attrs if present defp merge_bool_param(attrs, _key, nil), do: attrs defp merge_bool_param(attrs, key, "true"), do: Map.put(attrs, key, true) defp merge_bool_param(attrs, key, "false"), do: Map.put(attrs, key, false) defp merge_bool_param(attrs, _key, _), do: attrs # Check if IP is non-routable (RFC1918 private or RFC6598 CGNAT) defp non_routable_ip?(ip_string) do case ip_string |> String.to_charlist() |> :inet.parse_address() do {:ok, {a, b, _c, _d}} -> non_routable_ipv4_range?(a, b) # IPv6 or invalid - don't flag as non-routable _ -> false end end # 10.0.0.0/8 (RFC1918) defp non_routable_ipv4_range?(10, _b), do: true # 172.16.0.0/12 (RFC1918) defp non_routable_ipv4_range?(172, b) when b >= 16 and b <= 31, do: true # 192.168.0.0/16 (RFC1918) defp non_routable_ipv4_range?(192, 168), do: true # 100.64.0.0/10 (RFC6598 CGNAT) defp non_routable_ipv4_range?(100, b) when b >= 64 and b <= 127, do: true defp non_routable_ipv4_range?(_a, _b), do: false end