towerops/lib/towerops_web/live/device_live/form.ex
Graham McIntire d87461ba14
feat: add monitoring mode tab selector (SNMP & ICMP vs ICMP Only)
- Add tab selector in Monitoring Configuration section for choosing monitoring mode
- SNMP & ICMP mode: enables ICMP pings + SNMP discovery (default)
- ICMP Only mode: enables only ICMP ping monitoring, hides SNMP fields
- Monitoring mode controls snmp_enabled field automatically on save
- Test SNMP Connection button shows informational message when agent is configured
  (agents handle SNMP testing during their polling cycle)
- Update all tests to use tab selector instead of setting snmp_enabled directly
- All 818 tests passing with no Credo warnings
2026-01-17 16:10:59 -06:00

426 lines
14 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
@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"/orgs/#{organization.slug}/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)}
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
changeset =
socket.assigns.device
|> Devices.change_device(device_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
@impl true
def handle_event("save", %{"device" => device_params}, socket) do
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"/orgs/#{socket.assigns.organization.slug}/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
# Run discovery in background
_ =
Task.start(fn ->
Snmp.discover_device(device)
end)
{:noreply, put_flash(socket, :info, "Discovery started...")}
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)
{:noreply,
socket
|> put_flash(:info, flash_message)
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/devices/#{device.id}")}
{: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"/orgs/#{socket.assigns.organization.slug}/devices/#{device.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp handle_device_creation(device) do
if device.snmp_enabled do
_ = Task.start(fn -> Snmp.discover_device(device) end)
"Device created successfully. SNMP discovery started in background."
else
"Device created successfully"
end
end
defp handle_device_update(old_device, device) do
if should_trigger_snmp_discovery?(old_device, device) do
_ = Task.start(fn -> Snmp.discover_device(device) end)
"Device updated successfully. SNMP discovery started in background."
else
"Device updated successfully"
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(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(assigns) do
cond do
# For new devices, check preselected site
assigns[:preselected_site_id] ->
site = Enum.find(assigns.available_sites, &(&1.id == assigns.preselected_site_id))
site && site.snmp_community
# For edit mode, use effective SNMP community
assigns[:effective_snmp_community] && assigns.effective_snmp_community != "(not set)" ->
assigns.effective_snmp_community
# Fallback: check if there's only one site
length(assigns[:available_sites] || []) == 1 ->
site = List.first(assigns.available_sites)
site && site.snmp_community
true ->
nil
end
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
end