towerops/lib/towerops_web/live/device_live/form.ex
Graham McIntire b6ef989809
feat: redirect to device page after triggering rediscovery
When clicking 'Rediscover Device', navigate to the device details page
where the user can see live updates as discovery progresses.

The device show page already subscribes to PubSub discovery events and
will automatically refresh when discovery completes, showing updated
manufacturer, model, sensors, and interfaces.
2026-01-17 18:20:24 -06:00

454 lines
15 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"/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
# 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"/orgs/#{socket.assigns.organization.slug}/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"/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
enqueue_discovery(device.id)
"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
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, run synchronously
Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
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(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