577 lines
18 KiB
Elixir
577 lines
18 KiB
Elixir
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.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)
|
|
|
|
# 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, agents)
|
|
|> assign(:preselected_site_id, params["site_id"])
|
|
|> assign(:snmp_test_result, nil)
|
|
|> assign(:duplicate_device, nil)
|
|
|> assign(:non_routable_ip_error, false)}
|
|
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
|
|
}
|
|
|
|
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
|
|
device = Devices.get_device!(id)
|
|
|
|
# Preload necessary associations for agent resolution
|
|
device = Towerops.Repo.preload(device, site: [organization: :default_agent_token])
|
|
|
|
# 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, %{})
|
|
|
|
# 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)
|
|
|
|
case Devices.create_device(device_params) do
|
|
{:ok, device} ->
|
|
# Handle agent assignment after device creation
|
|
handle_agent_assignment(device.id, agent_token_id)
|
|
|
|
flash_message = handle_device_creation(device)
|
|
|
|
# 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(: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 Exq
|
|
{:ok, _job} = Exq.enqueue(Exq, "discovery", DiscoveryWorker, [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
|
|
defp check_non_routable_ip_cloud_error(device_params, assigns) do
|
|
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
|
|
|
|
# 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
|
|
|
|
# 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
|