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
339 lines
11 KiB
Elixir
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
|