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.
454 lines
15 KiB
Elixir
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
|