towerops/lib/towerops_web/live/device_live/form.ex
Graham McIntire 6c1f344b5e
fix: resolve all dialyzer unmatched return and pattern match warnings
Fixed dialyzer warnings across multiple files by properly handling return
values from functions that are called for side effects.

Changes:
- lib/towerops/agents.ex: Explicitly match broadcast and if-expression returns
- lib/towerops/devices.ex: Match worker start function returns in if-expressions
- lib/towerops/workers/agent_latency_evaluator.ex: Match broadcast return
- lib/towerops/workers/device_monitor_worker.ex: Match perform_check and handle_status_change returns, handle schedule_next_check errors
- lib/towerops/workers/device_poller_worker.ex: Match all PubSub broadcast returns, handle schedule_next_poll errors, remove unreachable pattern
- lib/towerops/workers/stale_agent_worker.ex: Match process_stale_agents and broadcast returns
- lib/towerops_web/channels/agent_channel.ex: Match PubSub.subscribe return, improve get_addr pattern matching, remove unreachable format_ip clause
- lib/towerops_web/live/device_live/form.ex: Match enqueue_discovery return, replace compile-time @dev_env with runtime check

Pattern applied:
- PubSub broadcasts: `_ = Phoenix.PubSub.broadcast(...)`
- If-expressions with side effects: `_ = if condition do ... end`
- Critical operations: Added error logging with case statements

All 3625 tests passing. No dialyzer warnings remaining in lib/towerops or lib/towerops_web.

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-24 17:34:37 -06:00

595 lines
19 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)
# Include cloud pollers in the available agents list
cloud_pollers = Agents.list_cloud_pollers()
all_agents = agents ++ cloud_pollers
# 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)}
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 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
# 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