towerops/lib/towerops_web/live/equipment_live/form.ex
Graham McIntire a2d96f8e6e
Implement hierarchical agent assignment for SNMP polling
Add three-level agent assignment hierarchy (Equipment > Site > Organization)
allowing flexible agent deployment strategies. Agents now receive only equipment
assigned to them through direct assignment or inheritance from site/organization
defaults.

Key changes:
- Add agent_token_id to Sites table with migration
- Implement get_effective_agent_token/1 for hierarchical resolution
- Add list_agent_polling_targets/1 to return polling targets per agent
- Update API config endpoint to use hierarchical polling targets
- Add agent assignment UI to equipment, site, and organization forms
- Show agent source (direct/site/org/none) in equipment form
- Add equipment count column to agent list
- Update terminology from "poll from server" to "cloud polling"

Tests:
- Add 8 comprehensive tests for list_agent_polling_targets/1
- Add end-to-end test for hierarchical config endpoint
- All 775 tests passing
2026-01-14 08:38:50 -06:00

339 lines
11 KiB
Elixir

defmodule ToweropsWeb.EquipmentLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Agents
alias Towerops.Equipment
alias Towerops.Equipment.Equipment, as: EquipmentSchema
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 equipment.")
|> 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 = Equipment.change_equipment(%EquipmentSchema{}, equipment_attrs)
socket
|> assign(:page_title, "New Equipment")
|> assign(:equipment, %EquipmentSchema{})
|> assign(:form, to_form(changeset))
end
defp apply_action(socket, :edit, %{"id" => id}) do
equipment = Equipment.get_equipment!(id)
# Preload necessary associations for agent resolution
equipment = Towerops.Repo.preload(equipment, site: [organization: :default_agent_token])
# Get current agent assignment if any
current_assignment = Agents.get_equipment_assignment(equipment.id)
# Get the effective agent and where it comes from
{effective_agent_id, agent_source} = Agents.get_effective_agent_token_with_source(equipment)
# For the form, we only want the equipment-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
equipment_with_agent = Map.put(equipment, :agent_token_id, agent_token_id)
changeset = Equipment.change_equipment(equipment, %{})
socket
|> assign(:page_title, "Edit Equipment")
|> assign(:equipment, equipment_with_agent)
|> assign(:form, to_form(changeset))
|> assign(:agent_source, agent_source)
|> assign(:effective_agent_name, effective_agent_name)
end
@impl true
def handle_event("validate", %{"equipment" => equipment_params}, socket) do
changeset =
socket.assigns.equipment
|> Equipment.change_equipment(equipment_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
@impl true
def handle_event("save", %{"equipment" => equipment_params}, socket) do
save_equipment(socket, socket.assigns.live_action, equipment_params)
end
@impl true
def handle_event("delete", _params, socket) do
case Equipment.delete_equipment(socket.assigns.equipment) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Equipment deleted successfully")
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/equipment")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Unable to delete equipment")}
end
end
@impl true
def handle_event("test_snmp", _params, socket) do
snmp_config = extract_snmp_config(socket.assigns.form)
result = test_snmp_connection(snmp_config)
{:noreply, assign(socket, :snmp_test_result, result)}
end
@impl true
def handle_event("trigger_discovery", _params, socket) do
equipment = socket.assigns.equipment
if equipment.snmp_enabled do
# Run discovery in background
Task.start(fn ->
Snmp.discover_equipment(equipment)
end)
{:noreply, put_flash(socket, :info, "Discovery started...")}
else
{:noreply, put_flash(socket, :error, "SNMP is not enabled for this equipment")}
end
end
defp save_equipment(socket, :new, equipment_params) do
# Extract agent_token_id before creating equipment
agent_token_id = Map.get(equipment_params, "agent_token_id")
equipment_params = Map.delete(equipment_params, "agent_token_id")
case Equipment.create_equipment(equipment_params) do
{:ok, equipment} ->
# Handle agent assignment after equipment creation
handle_agent_assignment(equipment.id, agent_token_id)
flash_message = handle_equipment_creation(equipment)
{:noreply,
socket
|> put_flash(:info, flash_message)
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/equipment/#{equipment.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp save_equipment(socket, :edit, equipment_params) do
old_equipment = socket.assigns.equipment
# Extract agent_token_id before updating equipment
agent_token_id = Map.get(equipment_params, "agent_token_id")
equipment_params = Map.delete(equipment_params, "agent_token_id")
case Equipment.update_equipment(old_equipment, equipment_params) do
{:ok, equipment} ->
# Handle agent assignment after equipment update
handle_agent_assignment(equipment.id, agent_token_id)
flash_message = handle_equipment_update(old_equipment, equipment)
{:noreply,
socket
|> put_flash(:info, flash_message)
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/equipment/#{equipment.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp handle_equipment_creation(equipment) do
if equipment.snmp_enabled do
Task.start(fn -> Snmp.discover_equipment(equipment) end)
"Equipment created successfully. SNMP discovery started in background."
else
"Equipment created successfully"
end
end
defp handle_equipment_update(old_equipment, equipment) do
if should_trigger_snmp_discovery?(old_equipment, equipment) do
Task.start(fn -> Snmp.discover_equipment(equipment) end)
"Equipment updated successfully. SNMP discovery started in background."
else
"Equipment updated successfully"
end
end
defp should_trigger_snmp_discovery?(old_equipment, equipment) do
equipment.snmp_enabled and
(!old_equipment.snmp_enabled or
snmp_config_changed?(old_equipment, equipment))
end
defp snmp_config_changed?(old_equipment, equipment) do
equipment.snmp_community != old_equipment.snmp_community or
equipment.snmp_version != old_equipment.snmp_version or
equipment.snmp_port != old_equipment.snmp_port or
equipment.ip_address != old_equipment.ip_address
end
@spec extract_snmp_config(Phoenix.HTML.Form.t()) :: %{
ip_address: String.t() | nil,
snmp_community: String.t() | nil,
snmp_version: String.t() | nil,
snmp_port: integer()
}
defp extract_snmp_config(form) do
form_data = form.data
params = form.params
%{
ip_address: params["ip_address"] || form_data.ip_address,
snmp_community: params["snmp_community"] || form_data.snmp_community,
snmp_version: params["snmp_version"] || form_data.snmp_version,
snmp_port: normalize_port(params["snmp_port"] || form_data.snmp_port || 161)
}
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"}
end
defp validate_test_snmp_input(%{snmp_community: ""}) do
{:error, "SNMP community string is required"}
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(equipment_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_equipment_assignment(equipment_id, nil)
else
Agents.update_equipment_assignment(equipment_id, agent_token_id)
end
end
defp handle_agent_assignment(equipment_id, nil) do
# No agent selected, remove any assignment
Agents.update_equipment_assignment(equipment_id, nil)
end
defp handle_agent_assignment(_equipment_id, _), do: :ok
end