rename equipment to device

This commit is contained in:
Graham McIntire 2026-01-17 14:48:46 -06:00
parent c88f2bbf96
commit a810e75fc4
No known key found for this signature in database
95 changed files with 3062 additions and 2950 deletions

View file

@ -148,7 +148,7 @@ defmodule Towerops.Admin do
ip_address: ip_address
})
# Delete organization (cascades to memberships, sites, equipment)
# Delete organization (cascades to memberships, sites, device)
Repo.delete!(org)
end)
end

View file

@ -3,14 +3,14 @@ defmodule Towerops.Agents do
The Agents context for managing remote SNMP polling agents.
This context provides functions for creating and managing agent authentication tokens,
assigning equipment to agents, and verifying agent credentials.
assigning devices to agents, and verifying agent credentials.
"""
import Ecto.Query, warn: false
alias Towerops.Agents.AgentAssignment
alias Towerops.Agents.AgentToken
alias Towerops.Equipment.Equipment
alias Towerops.Devices.Device
alias Towerops.Repo
## Token management
@ -58,18 +58,18 @@ defmodule Towerops.Agents do
end
@doc """
Counts the number of equipment directly assigned to an agent.
Counts the number of devices directly assigned to an agent.
This only counts equipment with explicit assignments, not equipment
This only counts devices with explicit assignments, not devices
that inherit the agent from site or organization defaults.
## Examples
iex> count_assigned_equipment(agent_token_id)
iex> count_assigned_devices(agent_token_id)
5
"""
def count_assigned_equipment(agent_token_id) do
def count_assigned_devices(agent_token_id) do
AgentAssignment
|> where([a], a.agent_token_id == ^agent_token_id and a.enabled == true)
|> Repo.aggregate(:count)
@ -165,60 +165,60 @@ defmodule Towerops.Agents do
## Assignment management
@doc """
Assigns equipment to an agent.
Assigns device to an agent.
Creates an agent assignment that links equipment to an agent token.
Equipment can only be assigned to one agent at a time due to unique constraint.
Creates an agent assignment that links device to an agent token.
Devices can only be assigned to one agent at a time due to unique constraint.
## Examples
iex> assign_equipment_to_agent(agent_id, equipment_id)
iex> assign_device_to_agent(agent_id, device_id)
{:ok, %AgentAssignment{}}
iex> assign_equipment_to_agent(agent_id, already_assigned_equipment_id)
iex> assign_device_to_agent(agent_id, already_assigned_device_id)
{:error, %Ecto.Changeset{}}
"""
def assign_equipment_to_agent(agent_token_id, equipment_id) do
def assign_device_to_agent(agent_token_id, device_id) do
%AgentAssignment{}
|> AgentAssignment.changeset(%{
agent_token_id: agent_token_id,
equipment_id: equipment_id
device_id: device_id
})
|> Repo.insert()
end
@doc """
Removes the agent assignment for a piece of equipment.
Removes the agent assignment for a piece of device.
## Examples
iex> unassign_equipment(equipment_id)
iex> unassign_device(device_id)
{1, nil}
"""
def unassign_equipment(equipment_id) do
def unassign_device(device_id) do
AgentAssignment
|> where([a], a.equipment_id == ^equipment_id)
|> where([a], a.device_id == ^device_id)
|> Repo.delete_all()
end
@doc """
Lists all equipment assigned to an agent.
Lists all devices assigned to an agent.
Returns equipment records with preloaded site and SNMP device associations.
Returns device records with preloaded site and SNMP device associations.
## Examples
iex> list_agent_equipment(agent_token_id)
[%Equipment{site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}]
iex> list_agent_devices(agent_token_id)
[%Device{site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}]
"""
def list_agent_equipment(agent_token_id) do
def list_agent_devices(agent_token_id) do
Repo.all(
from(e in Equipment,
from(e in Device,
join: a in AgentAssignment,
on: a.equipment_id == e.id,
on: a.device_id == e.id,
where: a.agent_token_id == ^agent_token_id and a.enabled == true,
preload: [:site, snmp_device: [:sensors, :interfaces]]
)
@ -226,31 +226,31 @@ defmodule Towerops.Agents do
end
@doc """
Lists all equipment that should be polled by an agent.
Lists all devices that should be polled by an agent.
This includes equipment that is:
This includes devices that is:
1. Directly assigned to this agent
2. At a site that has this agent as default
3. In an organization that has this agent as default (and not overridden by site or equipment)
3. In an organization that has this agent as default (and not overridden by site or device)
Returns equipment records with preloaded associations, filtered to only SNMP-enabled equipment.
Returns device records with preloaded associations, filtered to only SNMP-enabled devices.
## Examples
iex> list_agent_polling_targets(agent_token_id)
[%Equipment{snmp_enabled: true, site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}]
[%Device{snmp_enabled: true, site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}]
"""
@spec list_agent_polling_targets(Ecto.UUID.t()) :: [Equipment.t()]
@spec list_agent_polling_targets(Ecto.UUID.t()) :: [Device.t()]
def list_agent_polling_targets(agent_token_id) do
# Use SQL to filter equipment by effective agent assignment
# This avoids loading all equipment into memory and filtering in application code
# Use SQL to filter devices by effective agent assignment
# This avoids loading all devices into memory and filtering in application code
Repo.all(
from(e in Equipment,
from(e in Device,
join: s in assoc(e, :site),
join: o in assoc(s, :organization),
left_join: aa in AgentAssignment,
on: aa.equipment_id == e.id and aa.enabled == true,
on: aa.device_id == e.id and aa.enabled == true,
where:
e.snmp_enabled == true and
(aa.agent_token_id == ^agent_token_id or
@ -267,49 +267,49 @@ defmodule Towerops.Agents do
end
@doc """
Gets the agent assignment for a piece of equipment.
Gets the agent assignment for a piece of device.
Returns the assignment record if it exists, nil otherwise.
## Examples
iex> get_equipment_assignment(equipment_id)
iex> get_device_assignment(device_id)
%AgentAssignment{}
iex> get_equipment_assignment(unassigned_equipment_id)
iex> get_device_assignment(unassigned_device_id)
nil
"""
def get_equipment_assignment(equipment_id) do
Repo.get_by(AgentAssignment, equipment_id: equipment_id)
def get_device_assignment(device_id) do
Repo.get_by(AgentAssignment, device_id: device_id)
end
@doc """
Updates or creates an equipment assignment.
Updates or creates an device assignment.
If agent_token_id is nil, removes any existing assignment.
Otherwise, creates or updates the assignment.
## Examples
iex> update_equipment_assignment(equipment_id, agent_token_id)
iex> update_device_assignment(device_id, agent_token_id)
{:ok, %AgentAssignment{}}
iex> update_equipment_assignment(equipment_id, nil)
iex> update_device_assignment(device_id, nil)
{:ok, nil}
"""
def update_equipment_assignment(equipment_id, nil) do
def update_device_assignment(device_id, nil) do
# Remove assignment if setting to nil
unassign_equipment(equipment_id)
unassign_device(device_id)
{:ok, nil}
end
def update_equipment_assignment(equipment_id, agent_token_id) do
case get_equipment_assignment(equipment_id) do
def update_device_assignment(device_id, agent_token_id) do
case get_device_assignment(device_id) do
nil ->
# Create new assignment
assign_equipment_to_agent(agent_token_id, equipment_id)
assign_device_to_agent(agent_token_id, device_id)
existing ->
# Update existing assignment
@ -320,36 +320,36 @@ defmodule Towerops.Agents do
end
@doc """
Gets the effective agent token for a piece of equipment.
Gets the effective agent token for a piece of device.
Resolves the agent token using the following hierarchy:
1. Equipment's direct agent assignment (highest priority)
1. Device's direct agent assignment (highest priority)
2. Site's agent_token_id
3. Organization's default_agent_token_id (lowest priority)
Returns the agent_token_id if found, nil otherwise.
The equipment must be preloaded with site: [organization: :default_agent_token].
The device must be preloaded with site: [organization: :default_agent_token].
## Examples
iex> equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
iex> get_effective_agent_token(equipment)
iex> device = Repo.preload(device, site: [organization: :default_agent_token])
iex> get_effective_agent_token(device)
"agent-token-uuid"
iex> get_effective_agent_token(equipment_without_any_assignment)
nil
"""
def get_effective_agent_token(%Equipment{} = equipment) do
# 1. Check for direct equipment assignment
case get_equipment_assignment(equipment.id) do
def get_effective_agent_token(%Device{} = device) do
# device assignment
case get_device_assignment(device.id) do
%AgentAssignment{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
agent_token_id
_ ->
# 2. Check site's agent token
site = equipment.site
site = device.site
case site do
%{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
@ -370,29 +370,29 @@ defmodule Towerops.Agents do
Gets information about where the effective agent token comes from.
Returns a tuple of {agent_token_id, source} where source is one of:
- :equipment - Direct equipment assignment
- :device - Direct device assignment
- :site - Inherited from site
- :organization - Inherited from organization
- :none - No agent assigned
## Examples
iex> get_effective_agent_token_with_source(equipment)
{"token-id", :equipment}
iex> get_effective_agent_token_with_source(device)
{"token-id", :device}
iex> get_effective_agent_token_with_source(equipment_no_assignment)
{nil, :none}
"""
def get_effective_agent_token_with_source(%Equipment{} = equipment) do
# 1. Check for direct equipment assignment
case get_equipment_assignment(equipment.id) do
def get_effective_agent_token_with_source(%Device{} = device) do
# device assignment
case get_device_assignment(device.id) do
%AgentAssignment{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
{agent_token_id, :equipment}
{agent_token_id, :device}
_ ->
# 2. Check site's agent token
site = equipment.site
site = device.site
case site do
%{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->

View file

@ -1,8 +1,8 @@
defmodule Towerops.Agents.AgentAssignment do
@moduledoc """
Schema for assigning equipment to remote agents.
Schema for assigning devices to remote agents.
Each equipment can only be assigned to one agent at a time (unique constraint on equipment_id).
Each device can only be assigned to one agent at a time (unique constraint on device_id).
This ensures that polling is not duplicated across multiple agents.
"""
use Ecto.Schema
@ -11,7 +11,7 @@ defmodule Towerops.Agents.AgentAssignment do
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentToken
alias Towerops.Equipment.Equipment
alias Towerops.Devices.Device
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@ -19,7 +19,7 @@ defmodule Towerops.Agents.AgentAssignment do
field :enabled, :boolean, default: true
belongs_to :agent_token, AgentToken
belongs_to :equipment, Equipment
belongs_to :device, Device
timestamps(type: :utc_datetime)
end
@ -29,8 +29,8 @@ defmodule Towerops.Agents.AgentAssignment do
enabled: boolean(),
agent_token_id: Ecto.UUID.t(),
agent_token: NotLoaded.t() | AgentToken.t(),
equipment_id: Ecto.UUID.t(),
equipment: NotLoaded.t() | Equipment.t(),
device_id: Ecto.UUID.t(),
device: NotLoaded.t() | Device.t(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@ -40,16 +40,16 @@ defmodule Towerops.Agents.AgentAssignment do
## Examples
iex> changeset(%AgentAssignment{}, %{agent_token_id: token_id, equipment_id: equip_id})
iex> changeset(%AgentAssignment{}, %{agent_token_id: token_id, device_id: equip_id})
%Ecto.Changeset{valid?: true}
"""
def changeset(assignment, attrs) do
assignment
|> cast(attrs, [:agent_token_id, :equipment_id, :enabled])
|> validate_required([:agent_token_id, :equipment_id])
|> unique_constraint([:equipment_id])
|> cast(attrs, [:agent_token_id, :device_id, :enabled])
|> validate_required([:agent_token_id, :device_id])
|> unique_constraint([:device_id])
|> foreign_key_constraint(:agent_token_id)
|> foreign_key_constraint(:equipment_id)
|> foreign_key_constraint(:device_id)
end
end

View file

@ -9,7 +9,7 @@ defmodule Towerops.Agents.Stats do
alias Towerops.Agents.AgentAssignment
alias Towerops.Agents.AgentToken
alias Towerops.Equipment.Equipment
alias Towerops.Devices.Device
alias Towerops.Repo
alias Towerops.Snmp.SensorReading
@ -18,7 +18,7 @@ defmodule Towerops.Agents.Stats do
Returns a list of agents with their health metrics:
- Online status (last_seen_at within 5 minutes)
- Number of assigned equipment
- Number of assigned devices
- Last metric submission time
- Version information
@ -31,7 +31,7 @@ defmodule Towerops.Agents.Stats do
name: "DC1 Agent",
online: true,
last_seen_at: ~U[2026-01-14 12:00:00Z],
equipment_count: 15,
device_count: 15,
last_metric_at: ~U[2026-01-14 12:00:30Z],
version: "0.1.0",
hostname: "agent-dc1"
@ -57,8 +57,8 @@ defmodule Towerops.Agents.Stats do
agents = Repo.all(query)
# Get equipment counts for all agents in a single query
equipment_counts = get_batch_equipment_counts(Enum.map(agents, & &1.id))
# device counts for all agents in a single query
device_counts = get_batch_device_counts(Enum.map(agents, & &1.id))
# Get last metric times for all agents in a single query
last_metrics = get_batch_last_metric_times(Enum.map(agents, & &1.id))
@ -66,15 +66,15 @@ defmodule Towerops.Agents.Stats do
# Combine data for each agent
Enum.map(agents, fn agent ->
agent
|> Map.put(:equipment_count, Map.get(equipment_counts, agent.id, 0))
|> Map.put(:device_count, Map.get(device_counts, agent.id, 0))
|> Map.put(:last_metric_at, Map.get(last_metrics, agent.id))
end)
end
@doc """
Get equipment count breakdown by agent assignment source.
Get devices count breakdown by agent assignment source.
Returns counts for equipment assigned via:
Returns counts for device assigned via:
- Direct assignment
- Site default
- Organization default
@ -92,21 +92,21 @@ defmodule Towerops.Agents.Stats do
"""
def get_equipment_assignment_breakdown(organization_id) do
# Use a single SQL query with CASE to determine assignment source
# This avoids loading all equipment into memory and calling get_effective_agent_token_with_source
# device into memory and calling get_effective_agent_token_with_source
results =
Repo.all(
from(e in Equipment,
from(e in Device,
join: s in assoc(e, :site),
join: o in assoc(s, :organization),
left_join: aa in AgentAssignment,
on: aa.equipment_id == e.id and aa.enabled == true,
on: aa.device_id == e.id and aa.enabled == true,
where: s.organization_id == ^organization_id and e.snmp_enabled == true,
select: %{
source:
fragment(
"""
CASE
WHEN ? IS NOT NULL THEN 'equipment'
WHEN ? IS NOT NULL THEN 'device'
WHEN ? IS NOT NULL THEN 'site'
WHEN ? IS NOT NULL THEN 'organization'
ELSE 'cloud'
@ -123,7 +123,7 @@ defmodule Towerops.Agents.Stats do
# Count by assignment source
Enum.reduce(results, %{direct: 0, site: 0, organization: 0, cloud: 0}, fn %{source: source}, acc ->
case source do
"equipment" -> %{acc | direct: acc.direct + 1}
"device" -> %{acc | direct: acc.direct + 1}
"site" -> %{acc | site: acc.site + 1}
"organization" -> %{acc | organization: acc.organization + 1}
"cloud" -> %{acc | cloud: acc.cloud + 1}
@ -161,7 +161,7 @@ defmodule Towerops.Agents.Stats do
from(sr in SensorReading,
join: s in assoc(sr, :sensor),
join: d in assoc(s, :snmp_device),
join: e in assoc(d, :equipment),
join: e in assoc(d, :device),
where: sr.checked_at > ^twenty_four_hours_ago and e.id in subquery(polling_targets_subquery(agent_token_id)),
select: count(sr.id)
)
@ -173,7 +173,7 @@ defmodule Towerops.Agents.Stats do
from(is in Towerops.Snmp.InterfaceStat,
join: i in assoc(is, :interface),
join: d in assoc(i, :snmp_device),
join: e in assoc(d, :equipment),
join: e in assoc(d, :device),
where: is.checked_at > ^twenty_four_hours_ago and e.id in subquery(polling_targets_subquery(agent_token_id)),
select: count(is.id)
)
@ -187,7 +187,7 @@ defmodule Towerops.Agents.Stats do
from(sr in SensorReading,
join: s in assoc(sr, :sensor),
join: d in assoc(s, :snmp_device),
join: e in assoc(d, :equipment),
join: e in assoc(d, :device),
where: e.id in subquery(polling_targets_subquery(agent_token_id)),
select: max(sr.checked_at)
)
@ -231,13 +231,13 @@ defmodule Towerops.Agents.Stats do
@doc """
Get agents with high load (>50 devices).
Identifies agents that may need to be split or have their equipment redistributed.
Identifies agents that may need to be split or have their devices redistributed.
## Examples
iex> get_high_load_agents(org_id)
[
%{id: "uuid", name: "DC1 Agent", equipment_count: 75}
%{id: "uuid", name: "DC1 Agent", device_count: 75}
]
"""
def get_high_load_agents(organization_id, threshold \\ 50) do
@ -249,20 +249,20 @@ defmodule Towerops.Agents.Stats do
agents = Repo.all(query)
# Get equipment count for each agent, filter by threshold, and sort
# device count for each agent, filter by threshold, and sort
agents
|> Enum.map(fn agent ->
equipment_count = get_agent_equipment_count(agent.id)
Map.put(agent, :equipment_count, equipment_count)
device_count = get_agent_device_count(agent.id)
Map.put(agent, :device_count, device_count)
end)
|> Enum.filter(fn agent -> agent.equipment_count > threshold end)
|> Enum.sort_by(& &1.equipment_count, :desc)
|> Enum.filter(fn agent -> agent.device_count > threshold end)
|> Enum.sort_by(& &1.device_count, :desc)
end
@doc """
Get equipment that have no agent assigned and are not polling.
Get devices that have no agent assigned and are not polling.
Identifies orphaned equipment that may need agent assignment.
Identifies orphaned devices that may need agent assignment.
## Examples
@ -272,15 +272,15 @@ defmodule Towerops.Agents.Stats do
]
"""
def get_unmonitored_equipment(organization_id) do
# Use SQL to determine which equipment has no agent assigned
# This avoids loading all equipment into memory
# device has no agent assigned
# device into memory
Repo.all(
from(e in Equipment,
from(e in Device,
join: s in assoc(e, :site),
join: o in assoc(s, :organization),
left_join: aa in AgentAssignment,
on: aa.equipment_id == e.id and aa.enabled == true,
# Equipment has no agent via any method
on: aa.device_id == e.id and aa.enabled == true,
# Device has no agent via any method
where:
s.organization_id == ^organization_id and
e.snmp_enabled == true and
@ -303,40 +303,40 @@ defmodule Towerops.Agents.Stats do
defp polling_targets_subquery(agent_token_id) do
# This is a simplified version - in production you'd want to use
# Agents.list_agent_polling_targets/1 logic here
from(e in Equipment,
from(e in Device,
join: a in AgentAssignment,
on: a.equipment_id == e.id,
on: a.device_id == e.id,
where: a.agent_token_id == ^agent_token_id and a.enabled == true and e.snmp_enabled == true,
select: e.id
)
end
defp get_agent_equipment_count(agent_token_id) do
# Count equipment by using list_agent_polling_targets
defp get_agent_device_count(agent_token_id) do
# device by using list_agent_polling_targets
# This properly handles hierarchical assignment
agent_token_id
|> Towerops.Agents.list_agent_polling_targets()
|> length()
end
defp get_batch_equipment_counts(agent_token_ids) do
defp get_batch_device_counts(agent_token_ids) do
# For now, use the existing function per agent since list_agent_polling_targets
# handles complex hierarchical logic in application code
# TODO: Optimize this with a pure SQL solution if it becomes a bottleneck
Map.new(agent_token_ids, fn id -> {id, get_agent_equipment_count(id)} end)
Map.new(agent_token_ids, fn id -> {id, get_agent_device_count(id)} end)
end
defp get_batch_last_metric_times(agent_token_ids) do
# Get last metric time for all agents in a single query
# Group by equipment ID, then map to agent via assignments
# device ID, then map to agent via assignments
results =
Repo.all(
from(sr in SensorReading,
join: s in assoc(sr, :sensor),
join: d in assoc(s, :snmp_device),
join: e in assoc(d, :equipment),
join: e in assoc(d, :device),
join: aa in AgentAssignment,
on: aa.equipment_id == e.id and aa.enabled == true,
on: aa.device_id == e.id and aa.enabled == true,
where: aa.agent_token_id in ^agent_token_ids,
group_by: aa.agent_token_id,
select: {aa.agent_token_id, max(sr.checked_at)}

View file

@ -9,7 +9,7 @@ defmodule Towerops.Alerts do
alias Towerops.Repo
@doc """
Creates an alert for equipment status change.
Creates an alert for device status change.
"""
def create_alert(attrs) do
%Alert{}
@ -18,15 +18,15 @@ defmodule Towerops.Alerts do
end
@doc """
Returns the list of alerts for an equipment.
Returns the list of alerts for an device.
"""
def list_equipment_alerts(equipment_id, limit \\ 100) do
def list_devices_alerts(device_id, limit \\ 100) do
Repo.all(
from(a in Alert,
where: a.equipment_id == ^equipment_id,
where: a.device_id == ^device_id,
order_by: [desc: a.triggered_at],
limit: ^limit,
preload: [:equipment, :acknowledged_by]
preload: [:device, :acknowledged_by]
)
)
end
@ -38,13 +38,13 @@ defmodule Towerops.Alerts do
def list_organization_active_alerts(organization_id) do
Repo.all(
from(a in Alert,
join: e in assoc(a, :equipment),
join: e in assoc(a, :device),
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
where: a.alert_type == :equipment_down,
where: a.alert_type == :device_down,
where: is_nil(a.resolved_at),
order_by: [desc: a.triggered_at],
preload: [equipment: {e, site: s}, acknowledged_by: :acknowledged_by]
preload: [:acknowledged_by, device: {e, site: s}]
)
)
end
@ -55,10 +55,10 @@ defmodule Towerops.Alerts do
def count_active_alerts(organization_id) do
Repo.aggregate(
from(a in Alert,
join: e in assoc(a, :equipment),
join: e in assoc(a, :device),
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
where: a.alert_type == :equipment_down,
where: a.alert_type == :device_down,
where: is_nil(a.resolved_at)
),
:count
@ -84,12 +84,12 @@ defmodule Towerops.Alerts do
query =
from(a in Alert,
join: e in assoc(a, :equipment),
join: e in assoc(a, :device),
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
order_by: [desc: a.triggered_at],
limit: ^limit,
preload: [equipment: {e, site: s}, acknowledged_by: []]
preload: [device: {e, site: s}, acknowledged_by: []]
)
query =
@ -115,7 +115,7 @@ defmodule Towerops.Alerts do
where: s.organization_id == ^organization_id,
order_by: [desc: a.triggered_at],
limit: ^limit,
preload: [equipment: {e, site: s}, acknowledged_by: []]
preload: [device: {e, site: s}, acknowledged_by: []]
)
)
end
@ -126,7 +126,7 @@ defmodule Towerops.Alerts do
def get_alert!(id) do
Alert
|> Repo.get!(id)
|> Repo.preload([:equipment, :acknowledged_by])
|> Repo.preload([:device, :acknowledged_by])
end
@doc """
@ -164,12 +164,12 @@ defmodule Towerops.Alerts do
end
@doc """
Checks if there's an active alert of the same type for the equipment.
Checks if there's an active alert of the same type for the device.
"""
def has_active_alert?(equipment_id, alert_type) do
def has_active_alert?(device_id, alert_type) do
Repo.exists?(
from(a in Alert,
where: a.equipment_id == ^equipment_id,
where: a.device_id == ^device_id,
where: a.alert_type == ^alert_type,
where: is_nil(a.resolved_at)
)
@ -177,12 +177,12 @@ defmodule Towerops.Alerts do
end
@doc """
Gets the latest active alert of a specific type for equipment.
Gets the latest active alert of a specific type for device.
"""
def get_active_alert(equipment_id, alert_type) do
def get_active_alert(device_id, alert_type) do
Repo.one(
from(a in Alert,
where: a.equipment_id == ^equipment_id,
where: a.device_id == ^device_id,
where: a.alert_type == ^alert_type,
where: is_nil(a.resolved_at),
order_by: [desc: a.triggered_at],

View file

@ -6,19 +6,19 @@ defmodule Towerops.Alerts.Alert do
alias Ecto.Association.NotLoaded
alias Towerops.Accounts.User
alias Towerops.Equipment.Equipment
alias Towerops.Devices.Device
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "alerts" do
field :alert_type, Ecto.Enum, values: [:equipment_down, :equipment_up]
field :alert_type, Ecto.Enum, values: [:device_down, :device_up]
field :triggered_at, :utc_datetime
field :acknowledged_at, :utc_datetime
field :resolved_at, :utc_datetime
field :email_sent_at, :utc_datetime
field :message, :string
belongs_to :equipment, Equipment
belongs_to :device, Device
belongs_to :acknowledged_by, User
timestamps(type: :utc_datetime)
@ -26,14 +26,14 @@ defmodule Towerops.Alerts.Alert do
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
alert_type: :equipment_down | :equipment_up,
alert_type: :device_down | :device_up,
triggered_at: DateTime.t(),
acknowledged_at: DateTime.t() | nil,
resolved_at: DateTime.t() | nil,
email_sent_at: DateTime.t() | nil,
message: String.t() | nil,
equipment_id: Ecto.UUID.t(),
equipment: NotLoaded.t() | Equipment.t(),
device_id: Ecto.UUID.t(),
device: NotLoaded.t() | Devices.t(),
acknowledged_by_id: Ecto.UUID.t() | nil,
acknowledged_by: NotLoaded.t() | User.t() | nil,
inserted_at: DateTime.t(),
@ -44,7 +44,7 @@ defmodule Towerops.Alerts.Alert do
def changeset(alert, attrs) do
alert
|> cast(attrs, [
:equipment_id,
:device_id,
:alert_type,
:triggered_at,
:acknowledged_at,
@ -53,8 +53,8 @@ defmodule Towerops.Alerts.Alert do
:email_sent_at,
:message
])
|> validate_required([:equipment_id, :alert_type, :triggered_at])
|> foreign_key_constraint(:equipment_id)
|> validate_required([:device_id, :alert_type, :triggered_at])
|> foreign_key_constraint(:device_id)
|> foreign_key_constraint(:acknowledged_by_id)
end
end

View file

@ -6,7 +6,7 @@ defmodule Towerops.Alerts.AlertNotifier do
import Swoosh.Email
alias Towerops.Alerts.Alert
alias Towerops.Equipment
alias Towerops.Devices
alias Towerops.Mailer
alias Towerops.Organizations
@ -20,12 +20,12 @@ defmodule Towerops.Alerts.AlertNotifier do
@spec deliver_alert_notification(Alert.t()) ::
{:ok, [{:ok, Swoosh.Email.t()} | {:error, term()}]}
def deliver_alert_notification(%Alert{} = alert) do
equipment =
alert.equipment_id
|> Equipment.get_equipment!()
device =
alert.device_id
|> Devices.get_device!()
|> Towerops.Repo.preload(site: :organization)
site = equipment.site
site = device.site
organization = site.organization
# Get all owners and admins who should receive alerts
@ -34,31 +34,31 @@ defmodule Towerops.Alerts.AlertNotifier do
results =
Enum.map(recipients, fn user ->
case alert.alert_type do
:equipment_down -> deliver_equipment_down_alert(user.email, equipment, organization)
:equipment_up -> deliver_equipment_up_alert(user.email, equipment, organization)
:device_down -> deliver_equipment_down_alert(user.email, device, organization)
:device_up -> deliver_equipment_up_alert(user.email, device, organization)
end
end)
{:ok, results}
end
defp deliver_equipment_down_alert(recipient_email, equipment, organization) do
subject = "[#{organization.name}] Equipment Down: #{equipment.name}"
defp deliver_equipment_down_alert(recipient_email, device, organization) do
subject = "[#{organization.name}] Device Down: #{device.name}"
check_method = if equipment.snmp_enabled, do: "SNMP", else: "ping"
check_method = if device.snmp_enabled, do: "SNMP", else: "ping"
body = """
==============================
ALERT: Equipment Down
ALERT: Device Down
Organization: #{organization.name}
Equipment: #{equipment.name}
IP Address: #{equipment.ip_address}
Site: #{equipment.site.name}
Device: #{device.name}
IP Address: #{device.ip_address}
Site: #{device.site.name}
The equipment is not responding to #{check_method} checks.
The device is not responding to #{check_method} checks.
Please investigate as soon as possible.
@ -68,23 +68,23 @@ defmodule Towerops.Alerts.AlertNotifier do
deliver(recipient_email, subject, body)
end
defp deliver_equipment_up_alert(recipient_email, equipment, organization) do
subject = "[#{organization.name}] Equipment Recovered: #{equipment.name}"
defp deliver_equipment_up_alert(recipient_email, device, organization) do
subject = "[#{organization.name}] Device Recovered: #{device.name}"
check_method = if equipment.snmp_enabled, do: "SNMP", else: "ping"
check_method = if device.snmp_enabled, do: "SNMP", else: "ping"
body = """
==============================
RESOLVED: Equipment Recovered
RESOLVED: Device Recovered
Organization: #{organization.name}
Equipment: #{equipment.name}
IP Address: #{equipment.ip_address}
Site: #{equipment.site.name}
Device: #{device.name}
IP Address: #{device.ip_address}
Site: #{device.site.name}
The equipment is now responding to #{check_method} checks.
The device is now responding to #{check_method} checks.
==============================
"""

View file

@ -25,7 +25,7 @@ defmodule Towerops.Application do
{DNSCluster, query: Application.get_env(:towerops, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Towerops.PubSub},
# Start event logger (subscribes to PubSub)
Towerops.Equipment.EventLogger,
Towerops.Devices.EventLogger,
# Start monitoring supervisor
Towerops.Monitoring.Supervisor,
# Start a worker by calling: Towerops.Worker.start_link(arg)

300
lib/towerops/devices.ex Normal file
View file

@ -0,0 +1,300 @@
defmodule Towerops.Devices do
@moduledoc """
The Devices context.
"""
import Ecto.Query
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Devices.Event
alias Towerops.Repo
@doc """
Returns the list of devices for a site.
"""
def list_site_devices(site_id) do
Repo.all(from(e in DeviceSchema, where: e.site_id == ^site_id, order_by: [asc: e.name]))
end
@doc """
Returns the list of all devices for an organization (via sites).
Supports filtering by:
- site_id: Filter by specific site
- status: Filter by status ("up", "down", "unknown")
"""
def list_organization_devices(organization_id, filters \\ %{}) do
query =
from(e in DeviceSchema,
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
order_by: [asc: e.name],
preload: [site: s]
)
query =
if site_id = filters["site_id"] do
where(query, [e], e.site_id == ^site_id)
else
query
end
query =
if status = filters["status"] do
where(query, [e], e.status == ^status)
else
query
end
Repo.all(query)
end
@doc """
Returns the count of devices for an organization.
"""
def count_organization_devices(organization_id) do
Repo.aggregate(
from(e in DeviceSchema,
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id
),
:count
)
end
@doc """
Returns the count of devices for a site.
"""
def count_site_devices(site_id) do
Repo.aggregate(
from(e in DeviceSchema, where: e.site_id == ^site_id),
:count
)
end
@doc """
Returns the count of devices that is down for a site.
"""
def count_site_devices_down(site_id) do
Repo.aggregate(
from(e in DeviceSchema, where: e.site_id == ^site_id and e.status == "down"),
:count
)
end
@doc """
Returns the list of all devices with monitoring enabled.
"""
def list_monitored_devices do
Repo.all(
from(e in DeviceSchema,
where: e.monitoring_enabled == true,
order_by: [asc: e.name]
)
)
end
@doc """
Returns the list of all devices with SNMP enabled.
"""
def list_snmp_enabled_devices do
Repo.all(
from(e in DeviceSchema,
where: e.snmp_enabled == true,
order_by: [asc: e.name]
)
)
end
@doc """
Gets a single device.
"""
def get_device!(id) do
DeviceSchema
|> Repo.get!(id)
|> Repo.preload([:site])
end
@doc """
Gets device with full details including SNMP device, interfaces, and sensors.
"""
def get_device_with_details(id) do
DeviceSchema
|> Repo.get(id)
|> case do
nil ->
nil
device ->
Repo.preload(device, [:site, snmp_device: [:interfaces, :sensors]])
end
end
@doc """
Gets a single device belonging to a specific site.
"""
def get_site_device!(site_id, device_id) do
Repo.one!(from(e in DeviceSchema, where: e.id == ^device_id, where: e.site_id == ^site_id, preload: [:site]))
end
@doc """
Gets SNMP configuration for device with hierarchical fallback.
Falls back in this order:
1. Device-level configuration
2. Site-level configuration
3. Organization-level configuration
Returns a map with:
- version: SNMP version ("1", "2c", or "3")
- community: SNMP community string
- source: Where the config came from (:device, :site, or :organization)
"""
def get_snmp_config(device_id) when is_binary(device_id) do
device =
DeviceSchema
|> Repo.get!(device_id)
|> Repo.preload(site: :organization)
get_snmp_config(device)
end
def get_snmp_config(%DeviceSchema{} = device) do
# Ensure associations are loaded
device =
if Ecto.assoc_loaded?(device.site) do
if Ecto.assoc_loaded?(device.site.organization) do
device
else
Repo.preload(device, site: :organization)
end
else
Repo.preload(device, site: :organization)
end
cond do
# Device-level override
device.snmp_community != nil ->
%{
version: device.snmp_version || "2c",
community: device.snmp_community,
source: :device
}
# Site-level override
device.site.snmp_community != nil ->
%{
version: device.site.snmp_version || "2c",
community: device.site.snmp_community,
source: :site
}
# Organization-level default
device.site.organization.snmp_community != nil ->
%{
version: device.site.organization.snmp_version || "2c",
community: device.site.organization.snmp_community,
source: :organization
}
# No SNMP config set at any level
true ->
%{
version: "2c",
community: nil,
source: :default
}
end
end
@doc """
Creates device.
"""
def create_device(attrs) do
%DeviceSchema{}
|> DeviceSchema.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates device.
"""
def update_device(%DeviceSchema{} = device, attrs) do
device
|> DeviceSchema.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes device.
"""
def delete_device(%DeviceSchema{} = device) do
Repo.delete(device)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking device changes.
"""
def change_device(%DeviceSchema{} = device, attrs \\ %{}) do
DeviceSchema.changeset(device, attrs)
end
@doc """
Updates the status of device.
"""
def update_device_status(%DeviceSchema{} = device, new_status) do
now = DateTime.truncate(DateTime.utc_now(), :second)
changes = %{
status: new_status,
last_checked_at: now
}
changes =
if device.status == new_status do
changes
else
Map.put(changes, :last_status_change_at, now)
end
device
|> Ecto.Changeset.change(changes)
|> Repo.update()
end
@doc """
Updates the last SNMP poll timestamp for device.
Used for distributed coordination to prevent duplicate polling across pods.
"""
def update_snmp_poll_time(%DeviceSchema{} = device) do
now = DateTime.truncate(DateTime.utc_now(), :second)
device
|> Ecto.Changeset.change(%{last_snmp_poll_at: now})
|> Repo.update()
end
## Events
@doc """
Creates an device event.
"""
def create_event(attrs) do
%Event{}
|> Event.changeset(attrs)
|> Repo.insert()
end
@doc """
Returns the list of events for device, ordered by most recent first.
"""
def list_devices_events(device_id, limit \\ 100) do
Repo.all(
from(e in Event,
where: e.device_id == ^device_id,
order_by: [desc: e.occurred_at],
limit: ^limit
)
)
end
end

View file

@ -1,4 +1,4 @@
defmodule Towerops.Equipment.Equipment do
defmodule Towerops.Devices.Device do
@moduledoc false
use Ecto.Schema
@ -7,11 +7,11 @@ defmodule Towerops.Equipment.Equipment do
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentAssignment
alias Towerops.Sites.Site
alias Towerops.Snmp.Device
alias Towerops.Snmp.Device, as: SnmpDevice
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "equipment" do
schema "devices" do
field :name, :string
field :ip_address, :string
field :description, :string
@ -31,7 +31,7 @@ defmodule Towerops.Equipment.Equipment do
belongs_to :site, Site
has_one :snmp_device, Device
has_one :snmp_device, SnmpDevice
has_many :agent_assignments, AgentAssignment
timestamps(type: :utc_datetime)
@ -55,15 +55,15 @@ defmodule Towerops.Equipment.Equipment do
last_snmp_poll_at: DateTime.t() | nil,
site_id: Ecto.UUID.t(),
site: NotLoaded.t() | Site.t(),
snmp_device: NotLoaded.t() | Device.t() | nil,
snmp_device: NotLoaded.t() | SnmpDevice.t() | nil,
agent_assignments: NotLoaded.t() | [AgentAssignment.t()],
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc false
def changeset(equipment, attrs) do
equipment
def changeset(device, attrs) do
device
|> cast(attrs, [
:name,
:ip_address,

View file

@ -1,6 +1,6 @@
defmodule Towerops.Equipment.Event do
defmodule Towerops.Devices.Event do
@moduledoc """
Equipment event schema for tracking changes and incidents.
Device event schema for tracking changes and incidents.
"""
use Ecto.Schema
@ -26,14 +26,14 @@ defmodule Towerops.Equipment.Event do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "equipment_events" do
schema "device_events" do
field :event_type, :string
field :severity, :string
field :message, :string
field :metadata, :map, default: %{}
field :occurred_at, :utc_datetime
belongs_to :equipment, Towerops.Equipment.Equipment
belongs_to :device, Towerops.Devices.Device
timestamps(type: :utc_datetime, updated_at: false)
end
@ -41,8 +41,8 @@ defmodule Towerops.Equipment.Event do
@doc false
def changeset(event, attrs) do
event
|> cast(attrs, [:equipment_id, :event_type, :severity, :message, :metadata, :occurred_at])
|> validate_required([:equipment_id, :event_type, :severity, :message, :occurred_at])
|> cast(attrs, [:device_id, :event_type, :severity, :message, :metadata, :occurred_at])
|> validate_required([:device_id, :event_type, :severity, :message, :occurred_at])
|> validate_inclusion(:event_type, [
"interface_up",
"interface_down",
@ -59,6 +59,6 @@ defmodule Towerops.Equipment.Event do
"sensor_value_drop"
])
|> validate_inclusion(:severity, ["info", "warning", "critical"])
|> foreign_key_constraint(:equipment_id)
|> foreign_key_constraint(:device_id)
end
end

View file

@ -1,6 +1,6 @@
defmodule Towerops.Equipment.EventLogger do
defmodule Towerops.Devices.EventLogger do
@moduledoc """
GenServer that subscribes to equipment events via PubSub and logs them to the database.
GenServer that subscribes to device events via PubSub and logs them to the database.
This decouples event detection from event storage, allowing:
- Faster polling without database write blocking
@ -9,7 +9,7 @@ defmodule Towerops.Equipment.EventLogger do
"""
use GenServer
alias Towerops.Equipment
alias Towerops.Devices
require Logger
@ -23,15 +23,15 @@ defmodule Towerops.Equipment.EventLogger do
@impl true
def init(state) do
# Subscribe to equipment events topic
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:events")
Logger.info("EventLogger started and subscribed to equipment events")
# device events topic
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:events")
Logger.info("EventLogger started and subscribed to device events")
{:ok, state}
end
@impl true
def handle_info({:equipment_event, event_attrs}, state) do
case Equipment.create_event(event_attrs) do
def handle_info({:device_event, event_attrs}, state) do
case Devices.create_event(event_attrs) do
{:ok, event} ->
Logger.debug("Logged event: #{event.message}")

View file

@ -1,300 +0,0 @@
defmodule Towerops.Equipment do
@moduledoc """
The Equipment context.
"""
import Ecto.Query
alias Towerops.Equipment.Equipment, as: EquipmentSchema
alias Towerops.Equipment.Event
alias Towerops.Repo
@doc """
Returns the list of equipment for a site.
"""
def list_site_equipment(site_id) do
Repo.all(from(e in EquipmentSchema, where: e.site_id == ^site_id, order_by: [asc: e.name]))
end
@doc """
Returns the list of all equipment for an organization (via sites).
Supports filtering by:
- site_id: Filter by specific site
- status: Filter by status ("up", "down", "unknown")
"""
def list_organization_equipment(organization_id, filters \\ %{}) do
query =
from(e in EquipmentSchema,
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
order_by: [asc: e.name],
preload: [site: s]
)
query =
if site_id = filters["site_id"] do
where(query, [e], e.site_id == ^site_id)
else
query
end
query =
if status = filters["status"] do
where(query, [e], e.status == ^status)
else
query
end
Repo.all(query)
end
@doc """
Returns the count of equipment for an organization.
"""
def count_organization_equipment(organization_id) do
Repo.aggregate(
from(e in EquipmentSchema,
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id
),
:count
)
end
@doc """
Returns the count of equipment for a site.
"""
def count_site_equipment(site_id) do
Repo.aggregate(
from(e in EquipmentSchema, where: e.site_id == ^site_id),
:count
)
end
@doc """
Returns the count of equipment that is down for a site.
"""
def count_site_equipment_down(site_id) do
Repo.aggregate(
from(e in EquipmentSchema, where: e.site_id == ^site_id and e.status == "down"),
:count
)
end
@doc """
Returns the list of all equipment with monitoring enabled.
"""
def list_monitored_equipment do
Repo.all(
from(e in EquipmentSchema,
where: e.monitoring_enabled == true,
order_by: [asc: e.name]
)
)
end
@doc """
Returns the list of all equipment with SNMP enabled.
"""
def list_snmp_enabled_equipment do
Repo.all(
from(e in EquipmentSchema,
where: e.snmp_enabled == true,
order_by: [asc: e.name]
)
)
end
@doc """
Gets a single equipment.
"""
def get_equipment!(id) do
EquipmentSchema
|> Repo.get!(id)
|> Repo.preload([:site])
end
@doc """
Gets equipment with full details including SNMP device, interfaces, and sensors.
"""
def get_equipment_with_details(id) do
EquipmentSchema
|> Repo.get(id)
|> case do
nil ->
nil
equipment ->
Repo.preload(equipment, [:site, snmp_device: [:interfaces, :sensors]])
end
end
@doc """
Gets a single equipment belonging to a specific site.
"""
def get_site_equipment!(site_id, equipment_id) do
Repo.one!(from(e in EquipmentSchema, where: e.id == ^equipment_id, where: e.site_id == ^site_id, preload: [:site]))
end
@doc """
Gets SNMP configuration for equipment with hierarchical fallback.
Falls back in this order:
1. Equipment-level configuration
2. Site-level configuration
3. Organization-level configuration
Returns a map with:
- version: SNMP version ("1", "2c", or "3")
- community: SNMP community string
- source: Where the config came from (:equipment, :site, or :organization)
"""
def get_snmp_config(equipment_id) when is_binary(equipment_id) do
equipment =
EquipmentSchema
|> Repo.get!(equipment_id)
|> Repo.preload(site: :organization)
get_snmp_config(equipment)
end
def get_snmp_config(%EquipmentSchema{} = equipment) do
# Ensure associations are loaded
equipment =
if Ecto.assoc_loaded?(equipment.site) do
if Ecto.assoc_loaded?(equipment.site.organization) do
equipment
else
Repo.preload(equipment, site: :organization)
end
else
Repo.preload(equipment, site: :organization)
end
cond do
# Equipment-level override
equipment.snmp_community != nil ->
%{
version: equipment.snmp_version || "2c",
community: equipment.snmp_community,
source: :equipment
}
# Site-level override
equipment.site.snmp_community != nil ->
%{
version: equipment.site.snmp_version || "2c",
community: equipment.site.snmp_community,
source: :site
}
# Organization-level default
equipment.site.organization.snmp_community != nil ->
%{
version: equipment.site.organization.snmp_version || "2c",
community: equipment.site.organization.snmp_community,
source: :organization
}
# No SNMP config set at any level
true ->
%{
version: "2c",
community: nil,
source: :default
}
end
end
@doc """
Creates equipment.
"""
def create_equipment(attrs) do
%EquipmentSchema{}
|> EquipmentSchema.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates equipment.
"""
def update_equipment(%EquipmentSchema{} = equipment, attrs) do
equipment
|> EquipmentSchema.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes equipment.
"""
def delete_equipment(%EquipmentSchema{} = equipment) do
Repo.delete(equipment)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking equipment changes.
"""
def change_equipment(%EquipmentSchema{} = equipment, attrs \\ %{}) do
EquipmentSchema.changeset(equipment, attrs)
end
@doc """
Updates the status of equipment.
"""
def update_equipment_status(%EquipmentSchema{} = equipment, new_status) do
now = DateTime.truncate(DateTime.utc_now(), :second)
changes = %{
status: new_status,
last_checked_at: now
}
changes =
if equipment.status == new_status do
changes
else
Map.put(changes, :last_status_change_at, now)
end
equipment
|> Ecto.Changeset.change(changes)
|> Repo.update()
end
@doc """
Updates the last SNMP poll timestamp for equipment.
Used for distributed coordination to prevent duplicate polling across pods.
"""
def update_snmp_poll_time(%EquipmentSchema{} = equipment) do
now = DateTime.truncate(DateTime.utc_now(), :second)
equipment
|> Ecto.Changeset.change(%{last_snmp_poll_at: now})
|> Repo.update()
end
## Events
@doc """
Creates an equipment event.
"""
def create_event(attrs) do
%Event{}
|> Event.changeset(attrs)
|> Repo.insert()
end
@doc """
Returns the list of events for equipment, ordered by most recent first.
"""
def list_equipment_events(equipment_id, limit \\ 100) do
Repo.all(
from(e in Event,
where: e.equipment_id == ^equipment_id,
order_by: [desc: e.occurred_at],
limit: ^limit
)
)
end
end

View file

@ -19,17 +19,17 @@ defmodule Towerops.Monitoring do
end
@doc """
Returns the list of checks for an equipment.
Returns the list of checks for an device.
"""
def list_equipment_checks(equipment_id, limit \\ 100) do
Repo.all(from(c in Check, where: c.equipment_id == ^equipment_id, order_by: [desc: c.checked_at], limit: ^limit))
def list_devices_checks(device_id, limit \\ 100) do
Repo.all(from(c in Check, where: c.device_id == ^device_id, order_by: [desc: c.checked_at], limit: ^limit))
end
@doc """
Returns the latest check for an equipment.
Returns the latest check for an device.
"""
def get_latest_check(equipment_id) do
Repo.one(from(c in Check, where: c.equipment_id == ^equipment_id, order_by: [desc: c.checked_at], limit: 1))
def get_latest_check(device_id) do
Repo.one(from(c in Check, where: c.device_id == ^device_id, order_by: [desc: c.checked_at], limit: 1))
end
@doc """
@ -40,10 +40,10 @@ defmodule Towerops.Monitoring do
end
@doc """
Gets hourly statistics for equipment over a time range.
Gets hourly statistics for device over a time range.
Calculates aggregates on-demand from monitoring_checks.
"""
def get_hourly_stats(equipment_id, start_time, end_time) do
def get_hourly_stats(device_id, start_time, end_time) do
query = """
SELECT
date_trunc('hour', checked_at) as bucket,
@ -55,7 +55,7 @@ defmodule Towerops.Monitoring do
MAX(response_time_ms) as max_response_time_ms,
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'success') / NULLIF(COUNT(*), 0), 2) as uptime_percentage
FROM monitoring_checks
WHERE equipment_id = $1::uuid
WHERE device_id = $1::uuid
AND checked_at >= $2
AND checked_at <= $3
GROUP BY bucket
@ -65,15 +65,15 @@ defmodule Towerops.Monitoring do
SQL.query!(
Repo,
query,
[Ecto.UUID.dump!(equipment_id), start_time, end_time]
[Ecto.UUID.dump!(device_id), start_time, end_time]
)
end
@doc """
Gets daily statistics for equipment over a time range.
Gets daily statistics for device over a time range.
Calculates aggregates on-demand from monitoring_checks.
"""
def get_daily_stats(equipment_id, start_time, end_time) do
def get_daily_stats(device_id, start_time, end_time) do
query = """
SELECT
date_trunc('day', checked_at) as bucket,
@ -85,7 +85,7 @@ defmodule Towerops.Monitoring do
MAX(response_time_ms) as max_response_time_ms,
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'success') / NULLIF(COUNT(*), 0), 2) as uptime_percentage
FROM monitoring_checks
WHERE equipment_id = $1::uuid
WHERE device_id = $1::uuid
AND checked_at >= $2
AND checked_at <= $3
GROUP BY bucket
@ -95,26 +95,26 @@ defmodule Towerops.Monitoring do
SQL.query!(
Repo,
query,
[Ecto.UUID.dump!(equipment_id), start_time, end_time]
[Ecto.UUID.dump!(device_id), start_time, end_time]
)
end
@doc """
Gets uptime percentage for equipment over the last N days.
Gets uptime percentage for device over the last N days.
Calculates on-demand from monitoring_checks.
"""
def get_uptime_percentage(equipment_id, days_ago \\ 30) do
def get_uptime_percentage(device_id, days_ago \\ 30) do
start_time = DateTime.add(DateTime.utc_now(), -days_ago * 24 * 60 * 60, :second)
query = """
SELECT
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'success') / NULLIF(COUNT(*), 0), 2) as uptime_percentage
FROM monitoring_checks
WHERE equipment_id = $1::uuid
WHERE device_id = $1::uuid
AND checked_at >= $2
"""
case SQL.query!(Repo, query, [Ecto.UUID.dump!(equipment_id), start_time]) do
case SQL.query!(Repo, query, [Ecto.UUID.dump!(device_id), start_time]) do
%{rows: [[%Decimal{} = uptime]]} ->
uptime |> Decimal.to_float() |> Float.round(2)

View file

@ -11,7 +11,7 @@ defmodule Towerops.Monitoring.Check do
field :response_time_ms, :integer
field :checked_at, :utc_datetime
belongs_to :equipment, Towerops.Equipment.Equipment
belongs_to :device, Towerops.Devices.Device
timestamps(type: :utc_datetime, updated_at: false)
end
@ -19,8 +19,8 @@ defmodule Towerops.Monitoring.Check do
@doc false
def changeset(check, attrs) do
check
|> cast(attrs, [:equipment_id, :status, :response_time_ms, :checked_at])
|> validate_required([:equipment_id, :status, :checked_at])
|> foreign_key_constraint(:equipment_id)
|> cast(attrs, [:device_id, :status, :response_time_ms, :checked_at])
|> validate_required([:device_id, :status, :checked_at])
|> foreign_key_constraint(:device_id)
end
end

View file

@ -0,0 +1,221 @@
defmodule Towerops.Monitoring.DeviceMonitor do
@moduledoc """
GenServer that monitors a single piece of device by periodically pinging it.
"""
use GenServer
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Monitoring
require Logger
# Allow dependency injection for testing
@ping_module Application.compile_env(:towerops, :ping_module, Towerops.Monitoring.Ping)
@poller_module Application.compile_env(:towerops, :poller_module, Towerops.Snmp.Poller)
# Suppress warnings for Mox modules that are defined at runtime during tests
@compile {:no_warn_undefined, [Towerops.Monitoring.PingMock, Towerops.Snmp.PollerMock]}
# Client API
@doc """
Starts a monitor for the given device ID.
"""
def start_link(device_id) do
GenServer.start_link(__MODULE__, device_id, name: via_tuple(device_id))
end
@doc """
Triggers an immediate check for the device.
"""
def trigger_check(device_id) do
# Check if monitor process is running
case Registry.lookup(Towerops.Monitoring.Registry, device_id) do
[{_pid, _}] ->
# Process exists, send cast
GenServer.cast(via_tuple(device_id), :check_now)
[] ->
# Process doesn't exist (monitoring disabled), perform check directly
Task.start(fn -> perform_check(device_id) end)
end
end
# Server Callbacks
@impl true
def init(device_id) do
device = Devices.get_device!(device_id)
if device.monitoring_enabled do
# Perform immediate check when monitoring starts
send(self(), :check_device)
end
{:ok, %{device_id: device_id}}
end
@impl true
def handle_info(:check_device, state) do
_ = perform_check(state.device_id)
{:noreply, state}
end
@impl true
def handle_cast(:check_now, state) do
_ = perform_check(state.device_id)
{:noreply, state}
end
# Private Functions
defp perform_check(device_id) do
device = Devices.get_device!(device_id)
# Use SNMP if enabled, otherwise fallback to ping
check_result =
if device.snmp_enabled do
client_opts = @poller_module.build_client_opts(device)
@poller_module.check_device(client_opts)
else
@ping_module.ping(device.ip_address)
end
now = DateTime.truncate(DateTime.utc_now(), :second)
status =
case check_result do
{:ok, _time} -> :success
{:error, _reason} -> :failure
end
# Save the check result
case Monitoring.create_check(%{
device_id: device_id,
status: status,
response_time_ms: nil,
checked_at: now
}) do
{:ok, _check} ->
:ok
{:error, changeset} ->
Logger.error("Failed to create monitoring check for device #{device_id}: #{inspect(changeset.errors)}")
end
# device status if it changed
new_status = if status == :success, do: :up, else: :down
old_status = device.status
_ = Devices.update_device_status(device, new_status)
# Create alerts if status changed
_ =
if old_status != new_status do
handle_status_change(device, old_status, new_status)
end
# Broadcast status change via PubSub
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device_id}",
{:device_status_changed, device_id, new_status, nil}
)
# Only schedule next check if monitoring is enabled
if device.monitoring_enabled do
schedule_next_check(device.check_interval_seconds)
end
end
defp handle_status_change(device, old_status, new_status) do
now = DateTime.truncate(DateTime.utc_now(), :second)
case {old_status, new_status} do
{_, :down} ->
handle_equipment_down(device, now)
{_, :up} ->
handle_equipment_up(device, now)
end
end
defp handle_equipment_down(device, now) do
if Alerts.has_active_alert?(device.id, :device_down) do
:ok
else
create_device_down_alert(device, now)
end
end
defp create_device_down_alert(device, now) do
alert_message = get_down_alert_message(device)
{:ok, _alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: now,
message: alert_message
})
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:new",
{:new_alert, device.id, :device_down}
)
end
defp get_down_alert_message(device) do
if device.snmp_enabled do
"Device is not responding to SNMP"
else
"Device is not responding to ping"
end
end
defp handle_equipment_up(device, now) do
recovery_message = get_recovery_message(device)
{:ok, _alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_up,
triggered_at: now,
message: recovery_message
})
resolve_down_alert(device)
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:resolved",
{:alert_resolved, device.id, :device_down}
)
end
defp get_recovery_message(device) do
if device.snmp_enabled do
"Device is now responding to SNMP"
else
"Device is now responding to ping"
end
end
defp resolve_down_alert(device) do
case Alerts.get_active_alert(device.id, :device_down) do
nil -> :ok
alert -> Alerts.resolve_alert(alert)
end
end
defp schedule_next_check(interval_seconds) do
Process.send_after(self(), :check_device, interval_seconds * 1000)
end
defp via_tuple(device_id) do
{:via, Registry, {Towerops.Monitoring.Registry, device_id}}
end
end

View file

@ -1,221 +0,0 @@
defmodule Towerops.Monitoring.EquipmentMonitor do
@moduledoc """
GenServer that monitors a single piece of equipment by periodically pinging it.
"""
use GenServer
alias Towerops.Alerts
alias Towerops.Equipment
alias Towerops.Monitoring
require Logger
# Allow dependency injection for testing
@ping_module Application.compile_env(:towerops, :ping_module, Towerops.Monitoring.Ping)
@poller_module Application.compile_env(:towerops, :poller_module, Towerops.Snmp.Poller)
# Suppress warnings for Mox modules that are defined at runtime during tests
@compile {:no_warn_undefined, [Towerops.Monitoring.PingMock, Towerops.Snmp.PollerMock]}
# Client API
@doc """
Starts a monitor for the given equipment ID.
"""
def start_link(equipment_id) do
GenServer.start_link(__MODULE__, equipment_id, name: via_tuple(equipment_id))
end
@doc """
Triggers an immediate check for the equipment.
"""
def trigger_check(equipment_id) do
# Check if monitor process is running
case Registry.lookup(Towerops.Monitoring.Registry, equipment_id) do
[{_pid, _}] ->
# Process exists, send cast
GenServer.cast(via_tuple(equipment_id), :check_now)
[] ->
# Process doesn't exist (monitoring disabled), perform check directly
Task.start(fn -> perform_check(equipment_id) end)
end
end
# Server Callbacks
@impl true
def init(equipment_id) do
equipment = Equipment.get_equipment!(equipment_id)
if equipment.monitoring_enabled do
# Perform immediate check when monitoring starts
send(self(), :check_equipment)
end
{:ok, %{equipment_id: equipment_id}}
end
@impl true
def handle_info(:check_equipment, state) do
_ = perform_check(state.equipment_id)
{:noreply, state}
end
@impl true
def handle_cast(:check_now, state) do
_ = perform_check(state.equipment_id)
{:noreply, state}
end
# Private Functions
defp perform_check(equipment_id) do
equipment = Equipment.get_equipment!(equipment_id)
# Use SNMP if enabled, otherwise fallback to ping
check_result =
if equipment.snmp_enabled do
client_opts = @poller_module.build_client_opts(equipment)
@poller_module.check_device(client_opts)
else
@ping_module.ping(equipment.ip_address)
end
now = DateTime.truncate(DateTime.utc_now(), :second)
status =
case check_result do
{:ok, _time} -> :success
{:error, _reason} -> :failure
end
# Save the check result
case Monitoring.create_check(%{
equipment_id: equipment_id,
status: status,
response_time_ms: nil,
checked_at: now
}) do
{:ok, _check} ->
:ok
{:error, changeset} ->
Logger.error("Failed to create monitoring check for equipment #{equipment_id}: #{inspect(changeset.errors)}")
end
# Update equipment status if it changed
new_status = if status == :success, do: :up, else: :down
old_status = equipment.status
_ = Equipment.update_equipment_status(equipment, new_status)
# Create alerts if status changed
_ =
if old_status != new_status do
handle_status_change(equipment, old_status, new_status)
end
# Broadcast status change via PubSub
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:#{equipment_id}",
{:equipment_status_changed, equipment_id, new_status, nil}
)
# Only schedule next check if monitoring is enabled
if equipment.monitoring_enabled do
schedule_next_check(equipment.check_interval_seconds)
end
end
defp handle_status_change(equipment, old_status, new_status) do
now = DateTime.truncate(DateTime.utc_now(), :second)
case {old_status, new_status} do
{_, :down} ->
handle_equipment_down(equipment, now)
{_, :up} ->
handle_equipment_up(equipment, now)
end
end
defp handle_equipment_down(equipment, now) do
if Alerts.has_active_alert?(equipment.id, :equipment_down) do
:ok
else
create_equipment_down_alert(equipment, now)
end
end
defp create_equipment_down_alert(equipment, now) do
alert_message = get_down_alert_message(equipment)
{:ok, _alert} =
Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
triggered_at: now,
message: alert_message
})
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:new",
{:new_alert, equipment.id, :equipment_down}
)
end
defp get_down_alert_message(equipment) do
if equipment.snmp_enabled do
"Equipment is not responding to SNMP"
else
"Equipment is not responding to ping"
end
end
defp handle_equipment_up(equipment, now) do
recovery_message = get_recovery_message(equipment)
{:ok, _alert} =
Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_up,
triggered_at: now,
message: recovery_message
})
resolve_down_alert(equipment)
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:resolved",
{:alert_resolved, equipment.id, :equipment_down}
)
end
defp get_recovery_message(equipment) do
if equipment.snmp_enabled do
"Equipment is now responding to SNMP"
else
"Equipment is now responding to ping"
end
end
defp resolve_down_alert(equipment) do
case Alerts.get_active_alert(equipment.id, :equipment_down) do
nil -> :ok
alert -> Alerts.resolve_alert(alert)
end
end
defp schedule_next_check(interval_seconds) do
Process.send_after(self(), :check_equipment, interval_seconds * 1000)
end
defp via_tuple(equipment_id) do
{:via, Registry, {Towerops.Monitoring.Registry, equipment_id}}
end
end

View file

@ -1,6 +1,6 @@
defmodule Towerops.Monitoring.Ping do
@moduledoc """
Handles ping operations for equipment monitoring.
Handles ping operations for device monitoring.
"""
@behaviour Towerops.Monitoring.PingBehaviour

View file

@ -1,10 +1,10 @@
defmodule Towerops.Monitoring.Supervisor do
@moduledoc """
Supervisor for managing equipment monitoring workers.
Supervisor for managing device monitoring workers.
"""
use Supervisor
alias Towerops.Monitoring.EquipmentMonitor
alias Towerops.Monitoring.DeviceMonitor
alias Towerops.Snmp.NeighborCleanupWorker
alias Towerops.Snmp.PollerRegistry
alias Towerops.Snmp.PollerSupervisor
@ -47,7 +47,7 @@ defmodule Towerops.Monitoring.Supervisor do
try do
start_all_monitors()
Logger.info("Started all equipment monitors")
Logger.info("Started all devices monitors")
rescue
error ->
Logger.error("Failed to start monitors: #{inspect(error)}")
@ -67,19 +67,19 @@ defmodule Towerops.Monitoring.Supervisor do
end
@doc """
Starts monitoring for a specific equipment.
Starts monitoring for a specific device.
"""
def start_monitor(equipment_id) do
spec = {EquipmentMonitor, equipment_id}
def start_monitor(device_id) do
spec = {DeviceMonitor, device_id}
DynamicSupervisor.start_child(Towerops.Monitoring.DynamicSupervisor, spec)
end
@doc """
Stops monitoring for a specific equipment.
Stops monitoring for a specific device.
"""
def stop_monitor(equipment_id) do
case Registry.lookup(Towerops.Monitoring.Registry, equipment_id) do
def stop_monitor(device_id) do
case Registry.lookup(Towerops.Monitoring.Registry, device_id) do
[{pid, _}] ->
DynamicSupervisor.terminate_child(Towerops.Monitoring.DynamicSupervisor, pid)
@ -89,27 +89,27 @@ defmodule Towerops.Monitoring.Supervisor do
end
@doc """
Starts monitors for all equipment that has monitoring enabled.
Starts monitors for all devices that has monitoring enabled.
"""
def start_all_monitors do
Enum.each(Towerops.Equipment.list_monitored_equipment(), fn equipment ->
start_monitor(equipment.id)
Enum.each(Towerops.Devices.list_monitored_devices(), fn device ->
start_monitor(device.id)
end)
end
@doc """
Starts an SNMP poller for a specific equipment.
Starts an SNMP poller for a specific device.
"""
def start_snmp_poller(equipment_id) do
spec = {PollerWorker, equipment_id}
def start_snmp_poller(device_id) do
spec = {PollerWorker, device_id}
DynamicSupervisor.start_child(PollerSupervisor, spec)
end
@doc """
Stops an SNMP poller for a specific equipment.
Stops an SNMP poller for a specific device.
"""
def stop_snmp_poller(equipment_id) do
case Registry.lookup(PollerRegistry, equipment_id) do
def stop_snmp_poller(device_id) do
case Registry.lookup(PollerRegistry, device_id) do
[{pid, _}] ->
DynamicSupervisor.terminate_child(PollerSupervisor, pid)
@ -119,11 +119,11 @@ defmodule Towerops.Monitoring.Supervisor do
end
@doc """
Starts SNMP pollers for all equipment that has SNMP enabled.
Starts SNMP pollers for all devices that has SNMP enabled.
"""
def start_all_snmp_pollers do
Enum.each(Towerops.Equipment.list_snmp_enabled_equipment(), fn equipment ->
start_snmp_poller(equipment.id)
Enum.each(Towerops.Devices.list_snmp_enabled_devices(), fn device ->
start_snmp_poller(device.id)
end)
end

View file

@ -6,7 +6,7 @@ defmodule Towerops.Organizations do
import Ecto.Query
alias Towerops.Agents.AgentAssignment
alias Towerops.Equipment.Equipment
alias Towerops.Devices.Device
alias Towerops.Organizations.Invitation
alias Towerops.Organizations.Membership
alias Towerops.Organizations.Organization
@ -253,17 +253,17 @@ defmodule Towerops.Organizations do
## Bulk Configuration Updates
@doc """
Applies organization's SNMP configuration to all equipment in the organization.
Applies organization's SNMP configuration to all devices in the organization.
This updates all equipment records to use the organization's SNMP version and community string,
overwriting any existing equipment-level or site-level configurations.
This updates all devices records to use the organization's SNMP version and community string,
overwriting any existing device-level or site-level configurations.
Returns the number of equipment records updated.
Returns the number of device records updated.
"""
def apply_snmp_config_to_all_equipment(organization_id) do
organization = get_organization!(organization_id)
Repo.update_all(from(e in Equipment, join: s in assoc(e, :site), where: s.organization_id == ^organization_id),
Repo.update_all(from(e in Device, join: s in assoc(e, :site), where: s.organization_id == ^organization_id),
set: [
snmp_version: organization.snmp_version,
snmp_community: organization.snmp_community,
@ -273,33 +273,33 @@ defmodule Towerops.Organizations do
end
@doc """
Applies organization's default agent to all equipment in the organization.
Applies organization's default agent to all devices in the organization.
This creates/updates agent assignments for all equipment to use the organization's
default agent, overwriting any existing equipment-level or site-level assignments.
This creates/updates agent assignments for all devices to use the organization's
default agent, overwriting any existing device-level or site-level assignments.
Returns the number of equipment records assigned.
Returns the number of device records assigned.
"""
def apply_agent_to_all_equipment(organization_id) do
organization = Repo.get!(Organization, organization_id)
if organization.default_agent_token_id do
# Get all equipment IDs for this organization
equipment_ids =
# device IDs for this organization
device_ids =
Repo.all(
from(e in Equipment, join: s in assoc(e, :site), where: s.organization_id == ^organization_id, select: e.id)
from(e in Device, join: s in assoc(e, :site), where: s.organization_id == ^organization_id, select: e.id)
)
# Delete existing assignments
Repo.delete_all(from(a in AgentAssignment, where: a.equipment_id in ^equipment_ids))
Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids))
# Create new assignments
now = DateTime.truncate(DateTime.utc_now(), :second)
assignments =
Enum.map(equipment_ids, fn equipment_id ->
Enum.map(device_ids, fn device_id ->
%{
equipment_id: equipment_id,
device_id: device_id,
agent_token_id: organization.default_agent_token_id,
inserted_at: now,
updated_at: now
@ -310,12 +310,12 @@ defmodule Towerops.Organizations do
{count, nil}
else
# No default agent set, delete all assignments
equipment_ids =
device_ids =
Repo.all(
from(e in Equipment, join: s in assoc(e, :site), where: s.organization_id == ^organization_id, select: e.id)
from(e in Device, join: s in assoc(e, :site), where: s.organization_id == ^organization_id, select: e.id)
)
Repo.delete_all(from(a in AgentAssignment, where: a.equipment_id in ^equipment_ids))
Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids))
end
end
@ -326,7 +326,7 @@ defmodule Towerops.Organizations do
## Examples
iex> can?(membership, :edit, :equipment)
iex> can?(membership, :edit, :device)
true
iex> can?(membership, :delete, :organization)

View file

@ -15,7 +15,7 @@ defmodule Towerops.Organizations.Organization do
field :name, :string
field :slug, :string
# SNMP configuration (global default for all sites/equipment)
# device)
field :snmp_version, :string, default: "2c"
field :snmp_community, :string

View file

@ -13,7 +13,7 @@ defmodule Towerops.Organizations.Policy do
iex> can?(%Membership{role: :owner}, :delete, :organization)
true
iex> can?(%Membership{role: :viewer}, :edit, :equipment)
iex> can?(%Membership{role: :viewer}, :edit, :device)
false
"""
def can?(%Membership{role: role}, action, resource) do
@ -37,7 +37,7 @@ defmodule Towerops.Organizations.Policy do
defp check_permission(:member, action, :invitation) when action in [:create, :delete], do: false
defp check_permission(:member, action, resource)
when action in [:view, :list, :create, :edit] and resource in [:site, :equipment, :alert], do: true
when action in [:view, :list, :create, :edit] and resource in [:site, :device, :alert], do: true
# Viewer permissions - read-only
defp check_permission(:viewer, action, _resource) when action in [:view, :list], do: true

View file

@ -8,14 +8,14 @@ defmodule Towerops.Agent.AgentConfig do
field :version, 1, type: :string
field :poll_interval_seconds, 2, type: :uint32, json_name: "pollIntervalSeconds"
field :equipment, 3, repeated: true, type: Towerops.Agent.Equipment
field :devices, 3, repeated: true, type: Towerops.Agent.Device
end
defmodule Towerops.Agent.Equipment do
defmodule Towerops.Agent.Device do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.Equipment",
full_name: "towerops.agent.Device",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
@ -234,8 +234,8 @@ defmodule Towerops.Agent.AgentJob do
field :job_id, 1, type: :string, json_name: "jobId"
field :job_type, 2, type: Towerops.Agent.JobType, json_name: "jobType", enum: true
field :equipment_id, 3, type: :string, json_name: "equipmentId"
field :device, 4, type: Towerops.Agent.SnmpDevice
field :device_id, 3, type: :string, json_name: "deviceId"
field :snmp_device, 4, type: Towerops.Agent.SnmpDevice, json_name: "snmpDevice"
field :queries, 5, repeated: true, type: Towerops.Agent.SnmpQuery
end
@ -297,7 +297,7 @@ defmodule Towerops.Agent.SnmpResult do
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :equipment_id, 1, type: :string, json_name: "equipmentId"
field :device_id, 1, type: :string, json_name: "deviceId"
field :job_type, 2, type: Towerops.Agent.JobType, json_name: "jobType", enum: true
field :oid_values, 3, repeated: true, type: Towerops.Agent.SnmpResult.OidValuesEntry, json_name: "oidValues", map: true
field :timestamp, 4, type: :int64
@ -325,7 +325,7 @@ defmodule Towerops.Agent.AgentError do
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :equipment_id, 1, type: :string, json_name: "equipmentId"
field :device_id, 1, type: :string, json_name: "deviceId"
field :job_id, 2, type: :string, json_name: "jobId"
field :message, 3, type: :string
field :timestamp, 4, type: :int64

View file

@ -6,7 +6,7 @@ defmodule Towerops.Sites do
import Ecto.Query
alias Towerops.Agents.AgentAssignment
alias Towerops.Equipment.Equipment
alias Towerops.Devices.Device
alias Towerops.Repo
alias Towerops.Sites.Site
@ -55,7 +55,7 @@ defmodule Towerops.Sites do
def get_site!(id) do
Site
|> Repo.get!(id)
|> Repo.preload([:parent_site, :child_sites, :equipment])
|> Repo.preload([:parent_site, :child_sites, :device])
end
@doc """
@ -66,7 +66,7 @@ defmodule Towerops.Sites do
from(s in Site,
where: s.id == ^site_id,
where: s.organization_id == ^organization_id,
preload: [:parent_site, :child_sites, :equipment]
preload: [:parent_site, :child_sites, :device]
)
)
end
@ -127,17 +127,17 @@ defmodule Towerops.Sites do
## Bulk Configuration Updates
@doc """
Applies site's SNMP configuration to all equipment at the site.
Applies site's SNMP configuration to all devices at the site.
This updates all equipment records to use the site's SNMP version and community string,
overwriting any existing equipment-level configurations.
This updates all devices records to use the site's SNMP version and community string,
overwriting any existing device-level configurations.
Returns the number of equipment records updated.
Returns the number of device records updated.
"""
def apply_snmp_config_to_all_equipment(site_id) do
site = get_site!(site_id)
Repo.update_all(from(e in Equipment, where: e.site_id == ^site_id),
Repo.update_all(from(e in Device, where: e.site_id == ^site_id),
set: [
snmp_version: site.snmp_version,
snmp_community: site.snmp_community,
@ -147,30 +147,30 @@ defmodule Towerops.Sites do
end
@doc """
Applies site's agent to all equipment at the site.
Applies site's agent to all devices at the site.
This creates/updates agent assignments for all equipment at the site to use the site's
agent, overwriting any existing equipment-level assignments.
This creates/updates agent assignments for all devices at the site to use the site's
agent, overwriting any existing device-level assignments.
Returns the number of equipment records assigned.
Returns the number of device records assigned.
"""
def apply_agent_to_all_equipment(site_id) do
site = Repo.get!(Site, site_id)
# Get all equipment IDs for this site
equipment_ids = Repo.all(from(e in Equipment, where: e.site_id == ^site_id, select: e.id))
# device IDs for this site
device_ids = Repo.all(from(e in Device, where: e.site_id == ^site_id, select: e.id))
if site.agent_token_id do
# Delete existing assignments
Repo.delete_all(from(a in AgentAssignment, where: a.equipment_id in ^equipment_ids))
Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids))
# Create new assignments
now = DateTime.truncate(DateTime.utc_now(), :second)
assignments =
Enum.map(equipment_ids, fn equipment_id ->
Enum.map(device_ids, fn device_id ->
%{
equipment_id: equipment_id,
device_id: device_id,
agent_token_id: site.agent_token_id,
inserted_at: now,
updated_at: now
@ -181,7 +181,7 @@ defmodule Towerops.Sites do
{count, nil}
else
# No agent set, delete all assignments (they will inherit from org or use cloud)
Repo.delete_all(from(a in AgentAssignment, where: a.equipment_id in ^equipment_ids))
Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids))
end
end
end

View file

@ -6,7 +6,7 @@ defmodule Towerops.Sites.Site do
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentToken
alias Towerops.Equipment.Equipment
alias Towerops.Devices.Device
alias Towerops.Organizations.Organization
alias Towerops.Sites.Site
@ -25,7 +25,7 @@ defmodule Towerops.Sites.Site do
belongs_to :agent_token, AgentToken
belongs_to :parent_site, Site
has_many :child_sites, Site, foreign_key: :parent_site_id
has_many :equipment, Equipment
has_many :device, Device
timestamps(type: :utc_datetime)
end
@ -44,7 +44,7 @@ defmodule Towerops.Sites.Site do
parent_site_id: Ecto.UUID.t() | nil,
parent_site: NotLoaded.t() | t() | nil,
child_sites: NotLoaded.t() | [t()],
equipment: NotLoaded.t() | [Equipment.t()],
device: NotLoaded.t() | [Device.t()],
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}

View file

@ -7,7 +7,7 @@ defmodule Towerops.Snmp do
import Ecto.Query
alias Towerops.Equipment.Equipment
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Repo
alias Towerops.Snmp.Client
alias Towerops.Snmp.Device
@ -39,7 +39,7 @@ defmodule Towerops.Snmp do
end
@doc """
Runs SNMP discovery for a piece of equipment.
Runs SNMP discovery for a piece of device.
Discovers:
- Device information (manufacturer, model, firmware)
@ -50,15 +50,15 @@ defmodule Towerops.Snmp do
## Examples
iex> discover_equipment(equipment)
iex> discover_equipment(device)
{:ok, %Device{}}
"""
def discover_equipment(%Equipment{} = equipment) do
Discovery.discover_equipment(equipment)
def discover_equipment(%DeviceSchema{} = device) do
Discovery.discover_equipment(device)
end
@doc """
Runs SNMP discovery for all SNMP-enabled equipment in an organization.
Runs SNMP discovery for all SNMP-enabled devices in an organization.
Returns a summary of successful and failed discoveries.
@ -74,26 +74,26 @@ defmodule Towerops.Snmp do
# Device queries
@doc """
Gets the SNMP device for a piece of equipment.
Gets the SNMP device for a piece of device.
## Examples
iex> get_device(equipment_id)
iex> get_device(device_id)
%Device{}
iex> get_device(nonexistent_id)
nil
"""
def get_device(equipment_id) do
Repo.get_by(Device, equipment_id: equipment_id)
def get_device(device_id) do
Repo.get_by(Device, device_id: device_id)
end
@doc """
Gets the SNMP device for a piece of equipment with preloaded associations.
Gets the SNMP device for a device with preloaded associations.
"""
def get_device_with_associations(equipment_id) do
def get_device_with_associations(device_id) do
Device
|> where([d], d.equipment_id == ^equipment_id)
|> where([d], d.device_id == ^device_id)
|> preload([:interfaces, :sensors])
|> Repo.one()
end
@ -348,52 +348,52 @@ defmodule Towerops.Snmp do
# Neighbor queries
@doc """
Lists all neighbors for a piece of equipment.
Lists all neighbors for a piece of device.
"""
def list_neighbors(equipment_id) do
def list_neighbors(device_id) do
Neighbor
|> where([n], n.equipment_id == ^equipment_id)
|> where([n], n.device_id == ^device_id)
|> preload(:interface)
|> order_by([n], [n.protocol, n.remote_system_name])
|> Repo.all()
end
@doc """
Lists all neighbors for equipment with enriched data linking to known equipment.
Lists all neighbors for device with enriched data linking to known device.
Adds a `matched_equipment` field to each neighbor if we can identify the remote device.
"""
def list_neighbors_with_equipment(equipment_id) do
neighbors = list_neighbors(equipment_id)
def list_neighbors_with_equipment(device_id) do
neighbors = list_neighbors(device_id)
# Get the organization_id from the equipment to search within the same org
equipment = Towerops.Equipment.get_equipment!(equipment_id)
# device to search within the same org
device = Towerops.Devices.get_device!(device_id)
Enum.map(neighbors, fn neighbor ->
matched_equipment = find_matching_equipment(neighbor, equipment.site.organization_id)
Map.put(neighbor, :matched_equipment, matched_equipment)
matched_device = find_matching_equipment(neighbor, device.site.organization_id)
Map.put(neighbor, :matched_device, matched_device)
end)
end
# Try to match a neighbor to known equipment in the organization
# device in the organization
defp find_matching_equipment(neighbor, organization_id) do
# Try matching by chassis ID (MAC address) first
equipment_by_chassis =
if neighbor.remote_chassis_id && is_mac_address?(neighbor.remote_chassis_id) do
find_equipment_by_mac(neighbor.remote_chassis_id, organization_id)
find_device_by_mac(neighbor.remote_chassis_id, organization_id)
end
# Try matching by port ID (often contains MAC address in LLDP)
equipment_by_port =
if is_nil(equipment_by_chassis) && neighbor.remote_port_id &&
is_mac_address?(neighbor.remote_port_id) do
find_equipment_by_mac(neighbor.remote_port_id, organization_id)
find_device_by_mac(neighbor.remote_port_id, organization_id)
end
# Try matching by system name if MAC matches didn't work
equipment_by_name =
if is_nil(equipment_by_chassis) && is_nil(equipment_by_port) &&
neighbor.remote_system_name do
find_equipment_by_name(neighbor.remote_system_name, organization_id)
find_device_by_name(neighbor.remote_system_name, organization_id)
end
equipment_by_chassis || equipment_by_port || equipment_by_name
@ -407,7 +407,7 @@ defmodule Towerops.Snmp do
defp is_mac_address?(_), do: false
defp find_equipment_by_mac(mac_address, organization_id) do
defp find_device_by_mac(mac_address, organization_id) do
# MAC address might be in format "aa:bb:cc:dd:ee:ff", "aa-bb-cc-dd-ee-ff", or "aabbccddeeff"
# Normalize to just the hex digits for comparison
normalized_mac = mac_address |> String.downcase() |> String.replace(~r/[:-]/, "")
@ -416,8 +416,8 @@ defmodule Towerops.Snmp do
from(i in Interface,
join: d in Device,
on: i.snmp_device_id == d.id,
join: e in Equipment,
on: d.equipment_id == e.id,
join: e in DeviceSchema,
on: d.device_id == e.id,
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
where:
@ -429,11 +429,11 @@ defmodule Towerops.Snmp do
)
end
defp find_equipment_by_name(system_name, organization_id) do
defp find_device_by_name(system_name, organization_id) do
# Try exact match first, then partial match
normalized_name = String.downcase(system_name)
from(e in Equipment,
from(e in DeviceSchema,
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
where: fragment("LOWER(?)", e.name) == ^normalized_name,
@ -445,7 +445,7 @@ defmodule Towerops.Snmp do
nil ->
# Try partial match as fallback
Repo.one(
from(e in Equipment,
from(e in DeviceSchema,
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
where: fragment("LOWER(?) LIKE ?", e.name, ^"%#{normalized_name}%"),
@ -454,8 +454,8 @@ defmodule Towerops.Snmp do
)
)
equipment ->
equipment
device ->
device
end
end
@ -490,9 +490,9 @@ defmodule Towerops.Snmp do
@doc """
Deletes stale neighbors that haven't been seen since the given datetime.
"""
def delete_stale_neighbors(equipment_id, cutoff_datetime) do
def delete_stale_neighbors(device_id, cutoff_datetime) do
Neighbor
|> where([n], n.equipment_id == ^equipment_id)
|> where([n], n.device_id == ^device_id)
|> where([n], n.last_discovered_at < ^cutoff_datetime)
|> Repo.delete_all()
end

View file

@ -5,7 +5,7 @@ defmodule Towerops.Snmp.Device do
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Equipment.Equipment
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Sensor
@ -22,7 +22,7 @@ defmodule Towerops.Snmp.Device do
field :model, :string
field :firmware_version, :string
belongs_to :equipment, Equipment
belongs_to :device, DeviceSchema
has_many :interfaces, Interface, foreign_key: :snmp_device_id
has_many :sensors, Sensor, foreign_key: :snmp_device_id
@ -41,8 +41,8 @@ defmodule Towerops.Snmp.Device do
manufacturer: String.t() | nil,
model: String.t() | nil,
firmware_version: String.t() | nil,
equipment_id: Ecto.UUID.t(),
equipment: NotLoaded.t() | Equipment.t(),
device_id: Ecto.UUID.t(),
device: NotLoaded.t() | Devices.t(),
interfaces: NotLoaded.t() | [Interface.t()],
sensors: NotLoaded.t() | [Sensor.t()],
inserted_at: DateTime.t(),
@ -53,7 +53,7 @@ defmodule Towerops.Snmp.Device do
def changeset(device, attrs) do
device
|> cast(attrs, [
:equipment_id,
:device_id,
:sys_descr,
:sys_object_id,
:sys_name,
@ -64,8 +64,8 @@ defmodule Towerops.Snmp.Device do
:model,
:firmware_version
])
|> validate_required([:equipment_id])
|> unique_constraint(:equipment_id)
|> foreign_key_constraint(:equipment_id)
|> validate_required([:device_id])
|> unique_constraint(:device_id)
|> foreign_key_constraint(:device_id)
end
end

View file

@ -14,8 +14,8 @@ defmodule Towerops.Snmp.Discovery do
import Ecto.Query
alias Towerops.Equipment
alias Towerops.Equipment.Equipment, as: EquipmentSchema
alias Towerops.Devices
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Repo
alias Towerops.Snmp.Client
alias Towerops.Snmp.Device
@ -83,23 +83,23 @@ defmodule Towerops.Snmp.Discovery do
}
@doc """
Runs discovery for a single piece of equipment.
Runs discovery for a single piece of device.
Returns {:ok, device} or {:error, reason}.
## Examples
iex> discover_equipment(equipment)
iex> discover_equipment(device)
{:ok, %Device{manufacturer: "Cisco", model: "C2960"}}
iex> discover_equipment(equipment_without_snmp)
iex> discover_equipment(device_without_snmp)
{:error, :snmp_not_enabled}
"""
@spec discover_equipment(EquipmentSchema.t()) :: {:ok, Device.t()} | {:error, term()}
def discover_equipment(%EquipmentSchema{} = equipment) do
if equipment.snmp_enabled do
Logger.info("Starting SNMP discovery for equipment: #{equipment.name} (#{equipment.ip_address})")
@spec discover_equipment(DeviceSchema.t()) :: {:ok, Device.t()} | {:error, term()}
def discover_equipment(%DeviceSchema{} = device) do
if device.snmp_enabled do
Logger.info("Starting SNMP discovery for device: #{device.name} (#{device.ip_address})")
client_opts = build_client_opts(equipment)
client_opts = build_client_opts(device)
with {:ok, _} <- Client.test_connection(client_opts),
{:ok, system_info} <- discover_system(client_opts),
@ -107,24 +107,24 @@ defmodule Towerops.Snmp.Discovery do
{:ok, device_info} <- build_device_info(client_opts, system_info, profile),
{:ok, interfaces} <- discover_interfaces(client_opts, profile),
{:ok, sensors} <- discover_sensors(client_opts, profile),
{:ok, device} <- save_discovery_results(equipment, device_info, interfaces, sensors),
{:ok, neighbors} <- NeighborDiscovery.discover_neighbors(client_opts, device.interfaces),
:ok <- save_neighbors(equipment.id, neighbors) do
_ = update_equipment_discovery_time(equipment)
Logger.info("SNMP discovery completed successfully for: #{equipment.name}")
{:ok, discovered_device} <- save_discovery_results(device, device_info, interfaces, sensors),
{:ok, neighbors} <- NeighborDiscovery.discover_neighbors(client_opts, discovered_device.interfaces),
:ok <- save_neighbors(discovered_device.device_id, neighbors) do
_ = update_device_discovery_time(device)
Logger.info("SNMP discovery completed successfully for: #{device.name}")
# Broadcast discovery completion for real-time updates
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:#{equipment.id}",
{:discovery_completed, equipment.id}
"device:#{device.id}",
{:discovery_completed, device.id}
)
{:ok, device}
{:ok, discovered_device}
else
{:error, reason} = error ->
Logger.error("SNMP discovery failed for #{equipment.name}: #{inspect(reason)}")
Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
error
end
else
@ -133,21 +133,21 @@ defmodule Towerops.Snmp.Discovery do
end
@doc """
Runs discovery for all SNMP-enabled equipment in an organization.
Runs discovery for all SNMP-enabled devices in an organization.
Returns a summary of successful and failed discoveries.
"""
@spec discover_all(String.t()) :: {:ok, discovery_summary()}
def discover_all(org_id) do
equipment_list =
EquipmentSchema
device_list =
DeviceSchema
|> join(:inner, [e], s in assoc(e, :site))
|> where([e, s], s.organization_id == ^org_id and e.snmp_enabled == true)
|> Repo.all()
Logger.info("Starting SNMP discovery for #{length(equipment_list)} devices in org #{org_id}")
Logger.info("Starting SNMP discovery for #{length(device_list)} devices in org #{org_id}")
results =
equipment_list
device_list
|> Task.async_stream(
&discover_equipment/1,
max_concurrency: 5,
@ -171,16 +171,16 @@ defmodule Towerops.Snmp.Discovery do
# Private functions
@spec build_client_opts(EquipmentSchema.t()) :: Client.connection_opts()
defp build_client_opts(equipment) do
# Get SNMP config with hierarchical fallback (equipment -> site -> organization)
snmp_config = Equipment.get_snmp_config(equipment)
@spec build_client_opts(DeviceSchema.t()) :: Client.connection_opts()
defp build_client_opts(device) do
# Get SNMP config with hierarchical fallback (device -> site -> organization)
snmp_config = Devices.get_snmp_config(device)
[
ip: equipment.ip_address,
ip: device.ip_address,
community: snmp_config.community,
version: snmp_config.version,
port: equipment.snmp_port || 161,
port: device.snmp_port || 161,
timeout: 5000
]
end
@ -272,32 +272,32 @@ defmodule Towerops.Snmp.Discovery do
end
@spec save_discovery_results(
EquipmentSchema.t(),
DeviceSchema.t(),
device_info(),
[interface_data()],
[sensor_data()]
) :: {:ok, Device.t()} | {:error, term()}
defp save_discovery_results(equipment, device_info, interfaces, sensors) do
defp save_discovery_results(device, device_info, interfaces, sensors) do
Repo.transaction(fn ->
# Upsert Device
device = upsert_device(equipment, device_info)
device = upsert_device(device, device_info)
# Delete old interfaces/sensors and insert new ones
_ = delete_old_data(device)
new_interfaces = insert_interfaces(device, interfaces)
_ = insert_sensors(device, sensors)
# Return device with interfaces loaded and equipment_id added
%{device | interfaces: Enum.map(new_interfaces, &Map.put(&1, :equipment_id, equipment.id))}
# Return device with interfaces loaded and device_id added
%{device | interfaces: Enum.map(new_interfaces, &Map.put(&1, :device_id, device.id))}
end)
end
@spec upsert_device(EquipmentSchema.t(), device_info()) :: Device.t()
defp upsert_device(equipment, device_info) do
case Repo.get_by(Device, equipment_id: equipment.id) do
@spec upsert_device(DeviceSchema.t(), device_info()) :: Device.t()
defp upsert_device(device, device_info) do
case Repo.get_by(Device, device_id: device.id) do
nil ->
%Device{}
|> Device.changeset(Map.put(device_info, :equipment_id, equipment.id))
|> Device.changeset(Map.put(device_info, :device_id, device.id))
|> Repo.insert!()
existing_device ->
@ -332,26 +332,26 @@ defmodule Towerops.Snmp.Discovery do
end)
end
@spec update_equipment_discovery_time(EquipmentSchema.t()) ::
{:ok, EquipmentSchema.t()} | {:error, Ecto.Changeset.t()}
defp update_equipment_discovery_time(equipment) do
equipment
@spec update_device_discovery_time(DeviceSchema.t()) ::
{:ok, DeviceSchema.t()} | {:error, Ecto.Changeset.t()}
defp update_device_discovery_time(device) do
device
|> Ecto.Changeset.change(last_discovery_at: DateTime.truncate(DateTime.utc_now(), :second))
|> Repo.update()
end
@spec save_neighbors(binary(), [map()]) :: :ok
defp save_neighbors(equipment_id, neighbors) do
defp save_neighbors(device_id, neighbors) do
# Delete stale neighbors (not seen in last 5 minutes)
cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
Towerops.Snmp.delete_stale_neighbors(equipment_id, cutoff)
Towerops.Snmp.delete_stale_neighbors(device_id, cutoff)
# Upsert each discovered neighbor
Enum.each(neighbors, fn neighbor_data ->
Towerops.Snmp.upsert_neighbor(neighbor_data)
end)
Logger.info("Saved #{length(neighbors)} neighbors for equipment #{equipment_id}")
Logger.info("Saved #{length(neighbors)} neighbors for device #{device_id}")
:ok
end
end

View file

@ -18,7 +18,7 @@ defmodule Towerops.Snmp.Neighbor do
field :remote_capabilities, {:array, :string}, default: []
field :last_discovered_at, :utc_datetime
belongs_to :equipment, Towerops.Equipment.Equipment
belongs_to :device, Towerops.Devices.Device
belongs_to :interface, Towerops.Snmp.Interface
timestamps(type: :utc_datetime)
@ -29,12 +29,12 @@ defmodule Towerops.Snmp.Neighbor do
# Ensure UUIDs are properly formatted strings before casting
attrs =
attrs
|> Map.update(:equipment_id, nil, &normalize_uuid/1)
|> Map.update(:device_id, nil, &normalize_uuid/1)
|> Map.update(:interface_id, nil, &normalize_uuid/1)
neighbor
|> cast(attrs, [
:equipment_id,
:device_id,
:interface_id,
:protocol,
:remote_chassis_id,
@ -48,13 +48,13 @@ defmodule Towerops.Snmp.Neighbor do
:last_discovered_at
])
|> validate_required([
:equipment_id,
:device_id,
:interface_id,
:protocol,
:last_discovered_at
])
|> validate_inclusion(:protocol, ["lldp", "cdp"])
|> foreign_key_constraint(:equipment_id)
|> foreign_key_constraint(:device_id)
|> foreign_key_constraint(:interface_id)
|> unique_constraint([:interface_id, :remote_chassis_id, :protocol])
end

View file

@ -7,7 +7,7 @@ defmodule Towerops.Snmp.NeighborCleanupWorker do
"""
use GenServer
alias Towerops.Equipment
alias Towerops.Devices
alias Towerops.Snmp
require Logger
@ -41,14 +41,14 @@ defmodule Towerops.Snmp.NeighborCleanupWorker do
Logger.info("Starting neighbor cleanup for records older than #{cutoff}")
# Get all equipment IDs with SNMP enabled
equipment_list = Equipment.list_snmp_enabled_equipment()
# device IDs with SNMP enabled
equipment_list = Devices.list_snmp_enabled_devices()
total_deleted =
Enum.reduce(equipment_list, 0, fn equipment, acc ->
case Snmp.delete_stale_neighbors(equipment.id, cutoff) do
Enum.reduce(equipment_list, 0, fn device, acc ->
case Snmp.delete_stale_neighbors(device.id, cutoff) do
{count, _} when count > 0 ->
Logger.info("Deleted #{count} stale neighbors for equipment #{equipment.name} (#{equipment.id})")
Logger.info("Deleted #{count} stale neighbors for device #{device.name} (#{device.id})")
acc + count

View file

@ -14,7 +14,7 @@ defmodule Towerops.Snmp.NeighborDiscovery do
@type neighbor_data :: %{
required(:protocol) => String.t(),
required(:interface_id) => binary(),
required(:equipment_id) => binary(),
required(:device_id) => binary(),
optional(:remote_chassis_id) => String.t(),
optional(:remote_system_name) => String.t(),
optional(:remote_system_description) => String.t(),
@ -124,7 +124,7 @@ defmodule Towerops.Snmp.NeighborDiscovery do
%{
protocol: protocol,
interface_id: interface.id,
equipment_id: interface.equipment_id,
device_id: interface.device_id,
remote_chassis_id: sanitize_string_field(neighbor_map[:remote_chassis_id]),
remote_system_name: sanitize_string_field(neighbor_map[:remote_system_name]),
remote_system_description: sanitize_string_field(neighbor_map[:remote_system_description]),

View file

@ -45,16 +45,16 @@ defmodule Towerops.Snmp.Poller do
end
@doc """
Builds client options from equipment record.
Builds client options from device record.
"""
@impl true
@spec build_client_opts(map()) :: Client.connection_opts()
def build_client_opts(equipment) do
def build_client_opts(device) do
[
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.snmp_port || 161,
ip: device.ip_address,
community: device.snmp_community,
version: device.snmp_version,
port: device.snmp_port || 161,
timeout: 5000
]
end

View file

@ -7,12 +7,12 @@ defmodule Towerops.Snmp.PollerWorker do
- Interface statistics (bandwidth, errors, discards, etc.)
- Neighbor discovery (LLDP/CDP topology information)
Runs independently of the connectivity monitoring in EquipmentMonitor.
Poll interval is configurable per equipment (default: 60 seconds).
Runs independently of the connectivity monitoring in DeviceMonitor.
Poll interval is configurable per device (default: 60 seconds).
"""
use GenServer
alias Towerops.Equipment
alias Towerops.Devices
alias Towerops.Snmp
alias Towerops.Snmp.Client
alias Towerops.Snmp.NeighborDiscovery
@ -26,38 +26,38 @@ defmodule Towerops.Snmp.PollerWorker do
# Client API
@doc """
Starts a poller for the given equipment ID.
Starts a poller for the given device ID.
"""
def start_link(equipment_id) do
GenServer.start_link(__MODULE__, equipment_id, name: via_tuple(equipment_id))
def start_link(device_id) do
GenServer.start_link(__MODULE__, device_id, name: via_tuple(device_id))
end
@doc """
Triggers an immediate poll for the equipment.
Triggers an immediate poll for the device.
"""
def trigger_poll(equipment_id) do
case Registry.lookup(PollerRegistry, equipment_id) do
def trigger_poll(device_id) do
case Registry.lookup(PollerRegistry, device_id) do
[{_pid, _}] ->
GenServer.cast(via_tuple(equipment_id), :poll_now)
GenServer.cast(via_tuple(device_id), :poll_now)
[] ->
# Process doesn't exist, perform poll directly in background
Task.start(fn -> perform_poll(equipment_id) end)
Task.start(fn -> perform_poll(device_id) end)
end
end
# Server Callbacks
@impl true
def init(equipment_id) do
equipment = Equipment.get_equipment!(equipment_id)
def init(device_id) do
device = Devices.get_device!(device_id)
# Subscribe to discovery completion events for this equipment
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{equipment_id}")
# device
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}")
if equipment.snmp_enabled do
if device.snmp_enabled do
# Get the device to check if it has sensors/interfaces
device = Snmp.get_device_with_associations(equipment_id)
device = Snmp.get_device_with_associations(device_id)
if device && (device.sensors != [] || device.interfaces != []) do
# Perform immediate poll when starting
@ -65,17 +65,17 @@ defmodule Towerops.Snmp.PollerWorker do
end
end
{:ok, %{equipment_id: equipment_id}}
{:ok, %{device_id: device_id}}
end
@impl true
def handle_info(:poll_data, state) do
_ = perform_poll(state.equipment_id)
_ = perform_poll(state.device_id)
{:noreply, state}
end
@impl true
def handle_info({:discovery_completed, _equipment_id}, state) do
def handle_info({:discovery_completed, _device_id}, state) do
Logger.info("Discovery completed, triggering immediate poll")
# Trigger immediate poll after discovery
send(self(), :poll_data)
@ -84,100 +84,100 @@ defmodule Towerops.Snmp.PollerWorker do
@impl true
def handle_info(_msg, state) do
# Ignore other messages (like equipment status changes)
# device status changes)
{:noreply, state}
end
@impl true
def handle_cast(:poll_now, state) do
_ = perform_poll(state.equipment_id)
_ = perform_poll(state.device_id)
{:noreply, state}
end
# Private Functions
defp perform_poll(equipment_id) do
equipment = Equipment.get_equipment!(equipment_id)
defp perform_poll(device_id) do
device = Devices.get_device!(device_id)
if equipment.snmp_enabled do
poll_equipment_device(equipment_id, equipment)
if device.snmp_enabled do
poll_equipment_device(device_id, device)
end
end
defp poll_equipment_device(equipment_id, equipment) do
device = Snmp.get_device_with_associations(equipment_id)
defp poll_equipment_device(device_id, device) do
snmp_device = Snmp.get_device_with_associations(device_id)
if device do
poll_interval = get_poll_interval(equipment)
execute_device_poll(device, equipment, poll_interval)
if snmp_device do
poll_interval = get_poll_interval(device)
execute_device_poll(device, snmp_device, poll_interval)
schedule_next_poll(poll_interval)
end
end
defp execute_device_poll(device, equipment, poll_interval) do
if should_skip_poll?(equipment, poll_interval, 5) do
Logger.debug("Skipping poll for #{equipment.name} - recently polled by another process")
defp execute_device_poll(device, snmp_device, poll_interval) do
if should_skip_poll?(device, poll_interval, 5) do
Logger.debug("Skipping poll for #{device.name} - recently polled by another process")
else
perform_device_data_collection(device, equipment)
perform_device_data_collection(device, snmp_device)
end
end
defp perform_device_data_collection(device, equipment) do
Equipment.update_snmp_poll_time(equipment)
defp perform_device_data_collection(device, snmp_device) do
Devices.update_snmp_poll_time(device)
client_opts = build_client_opts(equipment)
client_opts = build_client_opts(device)
now = DateTime.truncate(DateTime.utc_now(), :second)
poll_device_sensors(device, equipment, client_opts, now)
poll_device_interfaces(device, equipment, client_opts, now)
check_device_interface_changes(device, equipment, client_opts, now)
poll_device_neighbors(device, equipment, client_opts)
poll_device_sensors(device, snmp_device, client_opts, now)
poll_device_interfaces(device, snmp_device, client_opts, now)
check_device_interface_changes(device, snmp_device, client_opts, now)
poll_device_neighbors(device, snmp_device, client_opts)
end
defp poll_device_sensors(device, equipment, client_opts, now) do
poll_sensors(device.sensors, client_opts, now)
Logger.debug("Polled #{length(device.sensors)} sensors for #{equipment.name}")
defp poll_device_sensors(device, snmp_device, client_opts, now) do
poll_sensors(snmp_device.sensors, client_opts, now)
Logger.debug("Polled #{length(snmp_device.sensors)} sensors for #{device.name}")
rescue
error ->
Logger.error("Error polling sensors for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
Logger.error("Error polling sensors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
end
defp poll_device_interfaces(device, equipment, client_opts, now) do
poll_interfaces(device.interfaces, client_opts, now)
Logger.debug("Polled #{length(device.interfaces)} interfaces for #{equipment.name}")
defp poll_device_interfaces(device, snmp_device, client_opts, now) do
poll_interfaces(snmp_device.interfaces, client_opts, now)
Logger.debug("Polled #{length(snmp_device.interfaces)} interfaces for #{device.name}")
rescue
error ->
Logger.error("Error polling interfaces for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
Logger.error("Error polling interfaces for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
end
defp check_device_interface_changes(device, equipment, client_opts, now) do
check_interface_changes(device.interfaces, equipment, client_opts, now)
defp check_device_interface_changes(device, snmp_device, client_opts, now) do
check_interface_changes(snmp_device.interfaces, device, client_opts, now)
rescue
error ->
Logger.error(
"Error checking interface changes for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}"
"Error checking interface changes for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}"
)
end
defp poll_device_neighbors(device, equipment, client_opts) do
# Add equipment_id to interfaces for neighbor discovery
interfaces_with_equipment = Enum.map(device.interfaces, &Map.put(&1, :equipment_id, equipment.id))
defp poll_device_neighbors(device, snmp_device, client_opts) do
# Add device_id to interfaces for neighbor discovery
interfaces_with_device = Enum.map(snmp_device.interfaces, &Map.put(&1, :device_id, snmp_device.id))
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces_with_equipment)
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces_with_device)
# Delete stale neighbors (not seen in last 5 minutes)
cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
Snmp.delete_stale_neighbors(equipment.id, cutoff)
Snmp.delete_stale_neighbors(device.id, cutoff)
# Upsert each discovered neighbor
Enum.each(neighbors, fn neighbor_data ->
Snmp.upsert_neighbor(neighbor_data)
end)
Logger.debug("Polled and saved #{length(neighbors)} neighbors for #{equipment.name}")
Logger.debug("Polled and saved #{length(neighbors)} neighbors for #{device.name}")
rescue
error ->
Logger.error("Error polling neighbors for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
Logger.error("Error polling neighbors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
end
defp poll_sensors(sensors, client_opts, timestamp) do
@ -319,11 +319,11 @@ defmodule Towerops.Snmp.PollerWorker do
end)
end
defp check_interface_changes(interfaces, equipment, client_opts, timestamp) do
defp check_interface_changes(interfaces, device, client_opts, timestamp) do
Enum.each(interfaces, fn interface ->
case get_interface_attributes(client_opts, interface.if_index) do
{:ok, current_attrs} ->
detect_and_log_changes(interface, current_attrs, equipment.id, timestamp)
detect_and_log_changes(interface, current_attrs, device.id, timestamp)
{:error, reason} ->
Logger.debug("Failed to get interface attributes for #{interface.if_name}: #{inspect(reason)}")
@ -365,13 +365,13 @@ defmodule Towerops.Snmp.PollerWorker do
Ecto.UUID.t(),
DateTime.t()
) :: :ok | nil
defp detect_and_log_changes(interface, current_attrs, equipment_id, timestamp) do
defp detect_and_log_changes(interface, current_attrs, device_id, timestamp) do
events =
[]
|> maybe_add_oper_status_event(interface, current_attrs, equipment_id, timestamp)
|> maybe_add_admin_status_event(interface, current_attrs, equipment_id, timestamp)
|> maybe_add_speed_change_event(interface, current_attrs, equipment_id, timestamp)
|> maybe_add_mac_change_event(interface, current_attrs, equipment_id, timestamp)
|> maybe_add_oper_status_event(interface, current_attrs, device_id, timestamp)
|> maybe_add_admin_status_event(interface, current_attrs, device_id, timestamp)
|> maybe_add_speed_change_event(interface, current_attrs, device_id, timestamp)
|> maybe_add_mac_change_event(interface, current_attrs, device_id, timestamp)
if events != [] do
broadcast_interface_events(events)
@ -379,16 +379,16 @@ defmodule Towerops.Snmp.PollerWorker do
end
end
defp maybe_add_oper_status_event(events, interface, current_attrs, equipment_id, timestamp) do
defp maybe_add_oper_status_event(events, interface, current_attrs, device_id, timestamp) do
if interface.if_oper_status == current_attrs.if_oper_status do
events
else
event = build_oper_status_event(interface, current_attrs, equipment_id, timestamp)
event = build_oper_status_event(interface, current_attrs, device_id, timestamp)
[{:event, event} | events]
end
end
defp build_oper_status_event(interface, current_attrs, equipment_id, timestamp) do
defp build_oper_status_event(interface, current_attrs, device_id, timestamp) do
message =
if interface.if_oper_status do
"Interface #{interface.if_name} changed from #{String.upcase(interface.if_oper_status)} to #{String.upcase(current_attrs.if_oper_status)}"
@ -397,7 +397,7 @@ defmodule Towerops.Snmp.PollerWorker do
end
%{
equipment_id: equipment_id,
device_id: device_id,
event_type: if(current_attrs.if_oper_status == "up", do: "interface_up", else: "interface_down"),
severity: if(current_attrs.if_oper_status == "up", do: "info", else: "warning"),
message: message,
@ -411,18 +411,18 @@ defmodule Towerops.Snmp.PollerWorker do
}
end
defp maybe_add_admin_status_event(events, interface, current_attrs, equipment_id, timestamp) do
defp maybe_add_admin_status_event(events, interface, current_attrs, device_id, timestamp) do
if interface.if_admin_status == current_attrs.if_admin_status do
events
else
event = build_admin_status_event(interface, current_attrs, equipment_id, timestamp)
event = build_admin_status_event(interface, current_attrs, device_id, timestamp)
[{:event, event} | events]
end
end
defp build_admin_status_event(interface, current_attrs, equipment_id, timestamp) do
defp build_admin_status_event(interface, current_attrs, device_id, timestamp) do
%{
equipment_id: equipment_id,
device_id: device_id,
event_type: "interface_admin_status_change",
severity: "info",
message:
@ -437,22 +437,22 @@ defmodule Towerops.Snmp.PollerWorker do
}
end
defp maybe_add_speed_change_event(events, interface, current_attrs, equipment_id, timestamp) do
defp maybe_add_speed_change_event(events, interface, current_attrs, device_id, timestamp) do
if interface.if_speed != current_attrs.if_speed && current_attrs.if_speed != nil do
event = build_speed_change_event(interface, current_attrs, equipment_id, timestamp)
event = build_speed_change_event(interface, current_attrs, device_id, timestamp)
[{:event, event} | events]
else
events
end
end
defp build_speed_change_event(interface, current_attrs, equipment_id, timestamp) do
defp build_speed_change_event(interface, current_attrs, device_id, timestamp) do
is_initial = interface.if_speed == nil
severity = determine_speed_change_severity(is_initial, interface.if_speed, current_attrs.if_speed)
message = format_speed_change_message(interface.if_name, is_initial, interface.if_speed, current_attrs.if_speed)
%{
equipment_id: equipment_id,
device_id: device_id,
event_type: "interface_speed_change",
severity: severity,
message: message,
@ -478,23 +478,23 @@ defmodule Towerops.Snmp.PollerWorker do
"Interface #{if_name} speed changed from #{format_speed(old_speed)} to #{format_speed(new_speed)}"
end
defp maybe_add_mac_change_event(events, interface, current_attrs, equipment_id, timestamp) do
defp maybe_add_mac_change_event(events, interface, current_attrs, device_id, timestamp) do
if interface.if_phys_address != current_attrs.if_phys_address && current_attrs.if_phys_address != nil do
event = build_mac_change_event(interface, current_attrs, equipment_id, timestamp)
event = build_mac_change_event(interface, current_attrs, device_id, timestamp)
[{:event, event} | events]
else
events
end
end
defp build_mac_change_event(interface, current_attrs, equipment_id, timestamp) do
defp build_mac_change_event(interface, current_attrs, device_id, timestamp) do
is_initial = interface.if_phys_address == nil
message =
format_mac_change_message(interface.if_name, is_initial, interface.if_phys_address, current_attrs.if_phys_address)
%{
equipment_id: equipment_id,
device_id: device_id,
event_type: "interface_mac_change",
severity: if(is_initial, do: "info", else: "warning"),
message: message,
@ -521,8 +521,8 @@ defmodule Towerops.Snmp.PollerWorker do
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:events",
{:equipment_event, event_attrs}
"device:events",
{:device_event, event_attrs}
)
Logger.debug("Broadcast event: #{event_attrs.message}")
@ -635,30 +635,30 @@ defmodule Towerops.Snmp.PollerWorker do
nil
end
defp build_client_opts(equipment) do
# Get SNMP config with hierarchical fallback (equipment -> site -> organization)
snmp_config = Equipment.get_snmp_config(equipment)
defp build_client_opts(device) do
# Get SNMP config with hierarchical fallback (device -> site -> organization)
snmp_config = Devices.get_snmp_config(device)
[
ip: equipment.ip_address,
ip: device.ip_address,
community: snmp_config.community,
version: snmp_config.version,
port: equipment.snmp_port || 161,
port: device.snmp_port || 161,
timeout: 5000
]
end
defp get_poll_interval(equipment) do
defp get_poll_interval(device) do
# Use check_interval_seconds, but minimum of 30 seconds for SNMP polling
max(equipment.check_interval_seconds || @default_poll_interval, 30)
max(device.check_interval_seconds || @default_poll_interval, 30)
end
defp schedule_next_poll(interval_seconds) do
Process.send_after(self(), :poll_data, interval_seconds * 1000)
end
defp should_skip_poll?(equipment, poll_interval_seconds, grace_period_seconds) do
case equipment.last_snmp_poll_at do
defp should_skip_poll?(device, poll_interval_seconds, grace_period_seconds) do
case device.last_snmp_poll_at do
nil ->
false
@ -678,13 +678,13 @@ defmodule Towerops.Snmp.PollerWorker do
if sensor.last_value == nil do
:ok
else
equipment_id = get_equipment_id_from_sensor(sensor)
device_id = get_device_id_from_sensor(sensor)
thresholds = extract_thresholds(sensor.metadata)
events =
[]
|> maybe_add_threshold_event(sensor, current_value, timestamp, equipment_id, thresholds)
|> maybe_add_change_event(sensor, current_value, timestamp, equipment_id)
|> maybe_add_threshold_event(sensor, current_value, timestamp, device_id, thresholds)
|> maybe_add_change_event(sensor, current_value, timestamp, device_id)
broadcast_sensor_events(events)
end
@ -705,8 +705,8 @@ defmodule Towerops.Snmp.PollerWorker do
}
end
defp maybe_add_threshold_event(events, sensor, current_value, timestamp, equipment_id, thresholds) do
threshold_event = check_threshold_violation(sensor, current_value, timestamp, equipment_id, thresholds)
defp maybe_add_threshold_event(events, sensor, current_value, timestamp, device_id, thresholds) do
threshold_event = check_threshold_violation(sensor, current_value, timestamp, device_id, thresholds)
if threshold_event, do: [threshold_event | events], else: events
end
@ -718,7 +718,7 @@ defmodule Towerops.Snmp.PollerWorker do
Ecto.UUID.t(),
map()
) :: map() | nil
defp check_threshold_violation(sensor, current_value, timestamp, equipment_id, thresholds) do
defp check_threshold_violation(sensor, current_value, timestamp, device_id, thresholds) do
threshold_checks = [
&check_critical_high/5,
&check_critical_low/5,
@ -727,8 +727,8 @@ defmodule Towerops.Snmp.PollerWorker do
]
Enum.find_value(threshold_checks, fn check_fn ->
check_fn.(sensor, current_value, timestamp, equipment_id, thresholds)
end) || check_returned_to_normal(sensor, current_value, timestamp, equipment_id, thresholds)
check_fn.(sensor, current_value, timestamp, device_id, thresholds)
end) || check_returned_to_normal(sensor, current_value, timestamp, device_id, thresholds)
end
@spec check_critical_high(
@ -738,13 +738,13 @@ defmodule Towerops.Snmp.PollerWorker do
Ecto.UUID.t(),
map()
) :: map() | nil
defp check_critical_high(sensor, current_value, timestamp, equipment_id, thresholds) do
defp check_critical_high(sensor, current_value, timestamp, device_id, thresholds) do
if thresholds.critical_high && current_value >= thresholds.critical_high do
build_threshold_event(
sensor,
current_value,
timestamp,
equipment_id,
device_id,
"critical_high",
thresholds.critical_high,
"critical",
@ -760,13 +760,13 @@ defmodule Towerops.Snmp.PollerWorker do
Ecto.UUID.t(),
map()
) :: map() | nil
defp check_critical_low(sensor, current_value, timestamp, equipment_id, thresholds) do
defp check_critical_low(sensor, current_value, timestamp, device_id, thresholds) do
if thresholds.critical_low && current_value <= thresholds.critical_low do
build_threshold_event(
sensor,
current_value,
timestamp,
equipment_id,
device_id,
"critical_low",
thresholds.critical_low,
"critical",
@ -782,13 +782,13 @@ defmodule Towerops.Snmp.PollerWorker do
Ecto.UUID.t(),
map()
) :: map() | nil
defp check_warning_high(sensor, current_value, timestamp, equipment_id, thresholds) do
defp check_warning_high(sensor, current_value, timestamp, device_id, thresholds) do
if thresholds.warning_high && current_value >= thresholds.warning_high do
build_threshold_event(
sensor,
current_value,
timestamp,
equipment_id,
device_id,
"warning_high",
thresholds.warning_high,
"warning",
@ -804,13 +804,13 @@ defmodule Towerops.Snmp.PollerWorker do
Ecto.UUID.t(),
map()
) :: map() | nil
defp check_warning_low(sensor, current_value, timestamp, equipment_id, thresholds) do
defp check_warning_low(sensor, current_value, timestamp, device_id, thresholds) do
if thresholds.warning_low && current_value <= thresholds.warning_low do
build_threshold_event(
sensor,
current_value,
timestamp,
equipment_id,
device_id,
"warning_low",
thresholds.warning_low,
"warning",
@ -823,7 +823,7 @@ defmodule Towerops.Snmp.PollerWorker do
sensor,
current_value,
timestamp,
equipment_id,
device_id,
threshold_type,
threshold_value,
severity,
@ -832,7 +832,7 @@ defmodule Towerops.Snmp.PollerWorker do
event_type = if severity == "critical", do: "sensor_threshold_critical", else: "sensor_threshold_warning"
build_sensor_event(
equipment_id,
device_id,
sensor,
event_type,
severity,
@ -847,13 +847,13 @@ defmodule Towerops.Snmp.PollerWorker do
)
end
defp check_returned_to_normal(sensor, current_value, timestamp, equipment_id, thresholds) do
defp check_returned_to_normal(sensor, current_value, timestamp, device_id, thresholds) do
was_over_threshold = value_was_over_threshold?(sensor.last_value, thresholds)
is_now_normal = value_is_normal?(current_value, thresholds)
if was_over_threshold && is_now_normal do
build_sensor_event(
equipment_id,
device_id,
sensor,
"sensor_threshold_normal",
"info",
@ -878,24 +878,24 @@ defmodule Towerops.Snmp.PollerWorker do
(thresholds.critical_low == nil || current_value > thresholds.critical_low)
end
defp maybe_add_change_event(events, sensor, current_value, timestamp, equipment_id) do
defp maybe_add_change_event(events, sensor, current_value, timestamp, device_id) do
# Only check for spikes/drops on percentage sensors
if sensor.sensor_unit == "%" do
change_event = check_significant_change(sensor, current_value, timestamp, equipment_id)
change_event = check_significant_change(sensor, current_value, timestamp, device_id)
if change_event, do: [change_event | events], else: events
else
events
end
end
defp check_significant_change(sensor, current_value, timestamp, equipment_id) do
defp check_significant_change(sensor, current_value, timestamp, device_id) do
change_percent = abs(current_value - sensor.last_value)
cond do
# Spike: increase of 30% or more
change_percent >= 30 && current_value > sensor.last_value ->
build_sensor_event(
equipment_id,
device_id,
sensor,
"sensor_value_spike",
"warning",
@ -907,7 +907,7 @@ defmodule Towerops.Snmp.PollerWorker do
# Drop: decrease of 30% or more
change_percent >= 30 && current_value < sensor.last_value ->
build_sensor_event(
equipment_id,
device_id,
sensor,
"sensor_value_drop",
"info",
@ -923,12 +923,12 @@ defmodule Towerops.Snmp.PollerWorker do
defp broadcast_sensor_events(events) do
Enum.each(events, fn event ->
_ = Phoenix.PubSub.broadcast(Towerops.PubSub, "equipment:events", {:equipment_event, event})
_ = Phoenix.PubSub.broadcast(Towerops.PubSub, "device:events", {:device_event, event})
Logger.debug("Sensor event: #{event.message}")
end)
end
defp build_sensor_event(equipment_id, sensor, event_type, severity, message, metadata, timestamp) do
defp build_sensor_event(device_id, sensor, event_type, severity, message, metadata, timestamp) do
base_metadata = %{
sensor_id: sensor.id,
sensor_name: sensor.sensor_descr,
@ -937,7 +937,7 @@ defmodule Towerops.Snmp.PollerWorker do
}
%{
equipment_id: equipment_id,
device_id: device_id,
event_type: event_type,
severity: severity,
message: message,
@ -946,11 +946,11 @@ defmodule Towerops.Snmp.PollerWorker do
}
end
defp get_equipment_id_from_sensor(sensor) do
# Get the device to find equipment_id
defp get_device_id_from_sensor(sensor) do
# Get the device to find device_id
case Towerops.Repo.get(Towerops.Snmp.Device, sensor.snmp_device_id) do
nil -> nil
device -> device.equipment_id
device -> device.device_id
end
end
@ -960,7 +960,7 @@ defmodule Towerops.Snmp.PollerWorker do
defp format_sensor_value(_, _), do: "N/A"
defp via_tuple(equipment_id) do
{:via, Registry, {PollerRegistry, equipment_id}}
defp via_tuple(device_id) do
{:via, Registry, {PollerRegistry, device_id}}
end
end

View file

@ -26,7 +26,7 @@ defmodule ToweropsWeb.AgentChannel do
alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.SnmpResult
alias Towerops.Agents
alias Towerops.Equipment
alias Towerops.Devices
alias Towerops.Snmp
alias Towerops.Snmp.Discovery
@ -108,7 +108,7 @@ defmodule ToweropsWeb.AgentChannel do
Logger.error("Agent job error",
agent_token_id: socket.assigns.agent_token_id,
equipment_id: error.equipment_id,
device_id: error.device_id,
error: error.message
)
@ -121,51 +121,51 @@ defmodule ToweropsWeb.AgentChannel do
defp build_jobs_for_agent(agent_token_id) do
agent_token_id
|> Agents.list_agent_polling_targets()
|> Enum.map(&build_job_for_equipment/1)
|> Enum.map(&build_job_for_device/1)
end
defp build_job_for_equipment(equipment) do
if needs_discovery?(equipment) do
build_discovery_job(equipment)
defp build_job_for_device(device) do
if needs_discovery?(device) do
build_discovery_job(device)
else
build_polling_job(equipment)
build_polling_job(device)
end
end
defp needs_discovery?(equipment) do
defp needs_discovery?(device) do
# Need discovery if no SNMP device or last discovery was >24 hours ago
is_nil(equipment.snmp_device) or
is_nil(equipment.last_discovery_at) or
DateTime.diff(DateTime.utc_now(), equipment.last_discovery_at, :hour) > 24
is_nil(device.snmp_device) or
is_nil(device.last_discovery_at) or
DateTime.diff(DateTime.utc_now(), device.last_discovery_at, :hour) > 24
end
defp build_discovery_job(equipment) do
defp build_discovery_job(device) do
%AgentJob{
job_id: "discover:#{equipment.id}",
job_id: "discover:#{device.id}",
job_type: :DISCOVER,
equipment_id: equipment.id,
device: %SnmpDevice{
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.snmp_port || 161
device_id: device.id,
snmp_device: %SnmpDevice{
ip: device.ip_address,
community: device.snmp_community,
version: device.snmp_version,
port: device.snmp_port || 161
},
queries: build_discovery_queries()
}
end
defp build_polling_job(equipment) do
device = equipment.snmp_device
defp build_polling_job(device) do
_snmp_device = device.snmp_device
%AgentJob{
job_id: "poll:#{equipment.id}",
job_id: "poll:#{device.id}",
job_type: :POLL,
equipment_id: equipment.id,
device: %SnmpDevice{
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.snmp_port || 161
device_id: device.id,
snmp_device: %SnmpDevice{
ip: device.ip_address,
community: device.snmp_community,
version: device.snmp_version,
port: device.snmp_port || 161
},
queries: build_polling_queries(device)
}
@ -272,48 +272,48 @@ defmodule ToweropsWeb.AgentChannel do
end
defp process_snmp_result(organization_id, result) do
case Equipment.get_equipment_with_details(result.equipment_id) do
case Devices.get_device_with_details(result.device_id) do
nil ->
Logger.error("Equipment not found: #{result.equipment_id}")
Logger.error("Device not found: #{result.device_id}")
equipment ->
# Verify equipment belongs to agent's organization
if equipment.site.organization_id == organization_id do
device ->
# Verify device belongs to agent's organization
if device.site.organization_id == organization_id do
case result.job_type do
:DISCOVER -> process_discovery_result(equipment, result)
:POLL -> process_polling_result(equipment, result)
:DISCOVER -> process_discovery_result(device, result)
:POLL -> process_polling_result(device, result)
end
else
Logger.error("Equipment #{result.equipment_id} not in agent's organization")
Logger.error("Device #{result.device_id} not in agent's organization")
end
end
end
defp process_discovery_result(equipment, _result) do
defp process_discovery_result(device, _result) do
# Agent confirmed device is reachable, trigger full discovery from server
# This hybrid approach simplifies the initial implementation:
# - Agent acts as remote executor
# - Server does SNMP queries and parsing using existing logic
Logger.info("Discovery results received for #{equipment.name}, triggering full discovery")
Logger.info("Discovery results received for #{device.name}, triggering full discovery")
Task.start(fn ->
case Discovery.discover_equipment(equipment) do
case Discovery.discover_equipment(device) do
{:ok, _device} ->
Logger.info("Full discovery completed for #{equipment.name}")
Logger.info("Full discovery completed for #{device.name}")
{:error, reason} ->
Logger.error("Discovery failed for #{equipment.name}: #{inspect(reason)}")
Logger.error("Discovery failed for #{device.name}: #{inspect(reason)}")
end
end)
end
defp process_polling_result(equipment, result) do
device = equipment.snmp_device
defp process_polling_result(device, result) do
snmp_device = device.snmp_device
oid_values = Map.new(result.oid_values)
timestamp = DateTime.from_unix!(result.timestamp, :second)
# Process sensor readings
Enum.each(device.sensors, fn sensor ->
Enum.each(snmp_device.sensors, fn sensor ->
if value = Map.get(oid_values, sensor.sensor_oid) do
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,

View file

@ -99,7 +99,7 @@ defmodule ToweropsWeb.Layouts do
attr :active_page, :string,
default: nil,
doc: "the currently active page (dashboard, sites, equipment, or alerts)"
doc: "the currently active page (dashboard, sites, device, or alerts)"
slot :inner_block, required: true
@ -152,10 +152,10 @@ defmodule ToweropsWeb.Layouts do
Sites
</.nav_link>
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}/equipment"}
active={@active_page == "equipment"}
navigate={~p"/orgs/#{@current_organization.slug}/devices"}
active={@active_page == "devices"}
>
Equipment
Devices
</.nav_link>
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}

View file

@ -7,7 +7,7 @@ defmodule ToweropsWeb.Api.MobileController do
use ToweropsWeb, :controller
alias Towerops.Alerts
alias Towerops.Equipment
alias Towerops.Devices
alias Towerops.Organizations
alias Towerops.Sites
@ -23,7 +23,7 @@ defmodule ToweropsWeb.Api.MobileController do
"id": "uuid",
"name": "Acme Corp",
"sites_count": 3,
"equipment_count": 15,
"device_count": 15,
"active_alerts_count": 2
}
]
@ -40,7 +40,7 @@ defmodule ToweropsWeb.Api.MobileController do
id: org.id,
name: org.name,
sites_count: Sites.count_organization_sites(org.id),
equipment_count: Equipment.count_organization_equipment(org.id),
device_count: Devices.count_organization_devices(org.id),
active_alerts_count: Alerts.count_active_alerts(org.id)
}
end)
@ -60,7 +60,7 @@ defmodule ToweropsWeb.Api.MobileController do
"id": "uuid",
"name": "Main Office",
"location": "New York, NY",
"equipment_count": 5,
"device_count": 5,
"equipment_down_count": 1
}
]
@ -79,8 +79,8 @@ defmodule ToweropsWeb.Api.MobileController do
id: site.id,
name: site.name,
location: site.location,
equipment_count: Equipment.count_site_equipment(site.id),
equipment_down_count: Equipment.count_site_equipment_down(site.id)
device_count: Devices.count_site_devices(site.id),
equipment_down_count: Devices.count_site_devices_down(site.id)
}
end)
@ -94,9 +94,9 @@ defmodule ToweropsWeb.Api.MobileController do
end
@doc """
GET /api/v1/mobile/organizations/:id/equipment
GET /api/v1/mobile/organizations/:id/devices
Returns list of equipment for an organization with current status.
Returns list of devices for an organization with current status.
Query params:
- site_id: Filter by site (optional)
@ -104,7 +104,7 @@ defmodule ToweropsWeb.Api.MobileController do
Response:
{
"equipment": [
"device": [
{
"id": "uuid",
"name": "Core Router",
@ -117,17 +117,17 @@ defmodule ToweropsWeb.Api.MobileController do
]
}
"""
def list_equipment(conn, %{"organization_id" => org_id} = params) do
def list_devices(conn, %{"organization_id" => org_id} = params) do
user = conn.assigns.current_user
case verify_organization_access(user, org_id) do
{:ok, _org} ->
equipment_list =
org_id
|> Equipment.list_organization_equipment(params)
|> Devices.list_organization_devices(params)
|> Enum.map(&format_equipment/1)
json(conn, %{equipment: equipment_list})
json(conn, %{device: equipment_list})
{:error, :unauthorized} ->
conn
@ -137,9 +137,9 @@ defmodule ToweropsWeb.Api.MobileController do
end
@doc """
GET /api/v1/mobile/equipment/:id
GET /api/v1/mobile/devices/:id
Returns detailed equipment information including interfaces and sensors.
Returns detailed device information including interfaces and sensors.
Response:
{
@ -153,24 +153,24 @@ defmodule ToweropsWeb.Api.MobileController do
"sensors": [...]
}
"""
def get_equipment(conn, %{"id" => equipment_id}) do
def get_device(conn, %{"id" => device_id}) do
user = conn.assigns.current_user
case Equipment.get_equipment_with_details(equipment_id) do
case Devices.get_device_with_details(device_id) do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "Equipment not found"})
|> json(%{error: "Device not found"})
equipment ->
case verify_organization_access(user, equipment.organization_id) do
device ->
case verify_organization_access(user, device.organization_id) do
{:ok, _org} ->
json(conn, format_equipment_details(equipment))
json(conn, format_equipment_details(device))
{:error, :unauthorized} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this equipment"})
|> json(%{error: "Access denied to this device"})
end
end
end
@ -192,9 +192,9 @@ defmodule ToweropsWeb.Api.MobileController do
"id": "uuid",
"severity": "critical",
"status": "active",
"message": "Equipment Down: Core Router",
"message": "Device Down: Core Router",
"equipment_name": "Core Router",
"equipment_id": "uuid",
"device_id": "uuid",
"occurred_at": "2026-01-15T19:44:25Z"
}
]
@ -231,31 +231,31 @@ defmodule ToweropsWeb.Api.MobileController do
end
end
defp format_equipment(equipment) do
defp format_equipment(device) do
%{
id: equipment.id,
name: equipment.name,
ip_address: equipment.ip_address,
site_name: equipment.site && equipment.site.name,
status: equipment.status || "unknown",
uptime: format_uptime(equipment),
last_seen_at: equipment.last_seen_at
id: device.id,
name: device.name,
ip_address: device.ip_address,
site_name: device.site && device.site.name,
status: device.status || "unknown",
uptime: format_uptime(device),
last_seen_at: device.last_seen_at
}
end
defp format_equipment_details(equipment) do
defp format_equipment_details(device) do
%{
id: equipment.id,
name: equipment.name,
ip_address: equipment.ip_address,
status: equipment.status || "unknown",
uptime: format_uptime(equipment),
id: device.id,
name: device.name,
ip_address: device.ip_address,
status: device.status || "unknown",
uptime: format_uptime(device),
site: %{
id: equipment.site.id,
name: equipment.site.name
id: device.site.id,
name: device.site.name
},
interfaces:
Enum.map(equipment.snmp_device.interfaces || [], fn interface ->
Enum.map(device.snmp_device.interfaces || [], fn interface ->
%{
id: interface.id,
name: interface.if_name,
@ -267,7 +267,7 @@ defmodule ToweropsWeb.Api.MobileController do
}
end),
sensors:
Enum.map(equipment.snmp_device.sensors || [], fn sensor ->
Enum.map(device.snmp_device.sensors || [], fn sensor ->
%{
id: sensor.id,
name: sensor.name,
@ -285,8 +285,8 @@ defmodule ToweropsWeb.Api.MobileController do
id: alert.id,
status: format_alert_status(alert),
message: alert.message,
equipment_name: alert.equipment && alert.equipment.name,
equipment_id: alert.equipment_id,
equipment_name: alert.device && alert.device.name,
device_id: alert.device_id,
occurred_at: alert.triggered_at
}
end
@ -299,9 +299,9 @@ defmodule ToweropsWeb.Api.MobileController do
end
end
defp format_uptime(equipment) do
if equipment.snmp_device && equipment.snmp_device.sys_uptime do
timeticks_to_string(equipment.snmp_device.sys_uptime)
defp format_uptime(device) do
if device.snmp_device && device.snmp_device.sys_uptime do
timeticks_to_string(device.snmp_device.sys_uptime)
end
end

View file

@ -17,7 +17,7 @@
for network operators.
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg tracking-tight text-slate-700">
Monitor network equipment via SNMP, track performance metrics in real-time, and respond to alerts instantly.
Monitor network devices via SNMP, track performance metrics in real-time, and respond to alerts instantly.
Built specifically for telecom tower infrastructure.
</p>
<div class="mt-10 flex justify-center gap-x-6">
@ -55,7 +55,7 @@
</div>
<p class="mt-4 text-sm text-blue-100">
Automatic device discovery via SNMP. Track interface statistics, sensor readings,
and equipment health with support for MikroTik, Cisco, and generic SNMP devices.
and device health with support for MikroTik, Cisco, and generic SNMP devices.
</p>
</div>
<!-- Feature 2 -->
@ -67,7 +67,7 @@
<h3 class="text-lg font-semibold text-white">Real-time Alerts</h3>
</div>
<p class="mt-4 text-sm text-blue-100">
Get instant notifications when equipment goes down or thresholds are exceeded.
Get instant notifications when devices go down or thresholds are exceeded.
Configurable alert rules with email notifications to keep you informed.
</p>
</div>
@ -99,7 +99,7 @@
Built for multi-site operations.
</h2>
<p class="mt-4 text-lg tracking-tight text-slate-700">
Organize equipment by site, collaborate with your team, and maintain clear visibility
Organize devices by site, collaborate with your team, and maintain clear visibility
across your entire network infrastructure.
</p>
</div>
@ -126,11 +126,11 @@
</div>
<h3 class="mt-6 text-sm font-medium text-blue-600">Site Management</h3>
<p class="mt-2 font-display text-xl text-slate-900">
Organize equipment by physical location
Organize devices by physical location
</p>
<p class="mt-4 text-sm text-slate-600">
Group devices by tower site or facility. View site-wide status and health metrics
at a glance, then drill down into individual equipment details.
at a glance, then drill down into individual device details.
</p>
</div>
<!-- Feature 2 -->

View file

@ -15,7 +15,7 @@
<button
phx-click="delete_org"
phx-value-id={org.id}
data-confirm="Are you sure? This will delete all sites, equipment, and data for this organization."
data-confirm="Are you sure? This will delete all sites, device, and data for this organization."
class="text-sm text-red-600 hover:text-red-800"
>
Delete

View file

@ -12,10 +12,10 @@ defmodule ToweropsWeb.AgentLive.Index do
organization = socket.assigns.current_organization
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
# Get equipment counts for each agent (both direct and total with inheritance)
# device counts for each agent (both direct and total with inheritance)
equipment_counts =
Map.new(agent_tokens, fn token ->
direct = Agents.count_assigned_equipment(token.id)
direct = Agents.count_assigned_devices(token.id)
total = length(Agents.list_agent_polling_targets(token.id))
{token.id, %{direct: direct, total: total}}
end)
@ -32,7 +32,7 @@ defmodule ToweropsWeb.AgentLive.Index do
socket
|> assign(:page_title, "Remote Agents")
|> assign(:agent_tokens, agent_tokens)
|> assign(:equipment_counts, equipment_counts)
|> assign(:device_counts, equipment_counts)
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:assignment_breakdown, assignment_breakdown)
|> assign(:offline_agents, offline_agents)
@ -61,10 +61,10 @@ defmodule ToweropsWeb.AgentLive.Index do
{:ok, agent_token, token} ->
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
# Refresh equipment counts
# device counts
equipment_counts =
Map.new(agent_tokens, fn t ->
direct = Agents.count_assigned_equipment(t.id)
direct = Agents.count_assigned_devices(t.id)
total = length(Agents.list_agent_polling_targets(t.id))
{t.id, %{direct: direct, total: total}}
end)
@ -77,7 +77,7 @@ defmodule ToweropsWeb.AgentLive.Index do
{:noreply,
socket
|> assign(:agent_tokens, agent_tokens)
|> assign(:equipment_counts, equipment_counts)
|> assign(:device_counts, equipment_counts)
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:assignment_breakdown, assignment_breakdown)
|> assign(:offline_agents, offline_agents)

View file

@ -84,8 +84,8 @@
</span>
</:col>
<:col :let={agent} label="Equipment">
<% counts = Map.get(@equipment_counts, agent.id, %{direct: 0, total: 0}) %>
<:col :let={agent} label="Device">
<% counts = Map.get(@device_counts, agent.id, %{direct: 0, total: 0}) %>
<div class="text-sm text-zinc-900 dark:text-zinc-100">
{counts.total} total
</div>
@ -217,7 +217,7 @@
type="button"
phx-hook="CopyToClipboard"
data-target="#agent-token-input"
id={"copy-token-#{@new_token.id}"}
id={"copy-token-#{@new_token.agent_token.id}"}
class="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-200 dark:hover:bg-zinc-700"
>
<.icon name="hero-clipboard" class="h-4 w-4" />
@ -264,7 +264,7 @@
type="button"
phx-hook="CopyToClipboard"
data-target="#docker-compose-example"
id={"copy-docker-compose-#{@new_token.id}"}
id={"copy-docker-compose-#{@new_token.agent_token.id}"}
class="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
>
<.icon name="hero-clipboard" class="h-3 w-3" /> Copy to clipboard

View file

@ -16,11 +16,11 @@ defmodule ToweropsWeb.AgentLive.Show do
raise Ecto.NoResultsError
end
# Get polling targets (all equipment this agent is responsible for)
# device this agent is responsible for)
polling_targets = Agents.list_agent_polling_targets(agent_token.id)
# Get direct assignments
direct_assignments = Agents.count_assigned_equipment(agent_token.id)
direct_assignments = Agents.count_assigned_devices(agent_token.id)
{:ok,
socket
@ -35,20 +35,20 @@ defmodule ToweropsWeb.AgentLive.Show do
{:noreply, socket}
end
defp assignment_source(equipment, agent_token_id) do
defp assignment_source(device, agent_token_id) do
cond do
# Direct assignment
Enum.any?(equipment.agent_assignments || [], fn a ->
Enum.any?(device.agent_assignments || [], fn a ->
a.agent_token_id == agent_token_id and a.enabled
end) ->
{:direct, "Directly assigned"}
# Site assignment
equipment.site && equipment.site.agent_token_id == agent_token_id ->
{:site, "From site: #{equipment.site.name}"}
device.site && device.site.agent_token_id == agent_token_id ->
{:site, "From site: #{device.site.name}"}
# Organization assignment
equipment.organization && equipment.organization.default_agent_token_id == agent_token_id ->
device.organization && device.organization.default_agent_token_id == agent_token_id ->
{:organization, "From organization"}
# Shouldn't happen but handle gracefully

View file

@ -36,11 +36,11 @@
</div>
</div>
<!-- Equipment Count Card -->
<!-- Device Count Card -->
<div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Equipment</p>
<p class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Device</p>
<p class="mt-2 text-3xl font-semibold text-zinc-900 dark:text-zinc-100">
{length(@polling_targets)}
</p>
@ -186,14 +186,14 @@
</dl>
</div>
<!-- Equipment List -->
<!-- Device List -->
<div class="mt-6 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg">
<div class="p-6 border-b border-zinc-200 dark:border-zinc-800">
<h3 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
Polling Targets
</h3>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Equipment that this agent is responsible for polling
Device that this agent is responsible for polling
</p>
</div>
@ -201,17 +201,17 @@
<div class="p-12 text-center">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" />
<h3 class="mt-4 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
No equipment assigned
No devices assigned
</h3>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
This agent is not currently polling any equipment.
This agent is not currently polling any device.
</p>
</div>
<% else %>
<div class="divide-y divide-zinc-200 dark:divide-zinc-800">
<%= for equipment <- @polling_targets do %>
<%= for device <- @polling_targets do %>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{equipment.id}"}
navigate={~p"/orgs/#{@organization.slug}/devices/#{device.id}"}
class="block hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<div class="p-6">
@ -219,9 +219,9 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3">
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-100 truncate">
{equipment.name}
{device.name}
</p>
<% {source, source_label} = assignment_source(equipment, @agent_token.id) %>
<% {source, source_label} = assignment_source(device, @agent_token.id) %>
<span class={"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium #{source_badge_class(source)}"}>
{source_label}
</span>
@ -229,12 +229,12 @@
<div class="mt-1 flex items-center gap-4 text-sm text-zinc-600 dark:text-zinc-400">
<span class="flex items-center gap-1">
<.icon name="hero-globe-alt" class="h-4 w-4" />
{equipment.ip_address}
{device.ip_address}
</span>
<%= if equipment.site do %>
<%= if device.site do %>
<span class="flex items-center gap-1">
<.icon name="hero-building-office" class="h-4 w-4" />
{equipment.site.name}
{device.site.name}
</span>
<% end %>
</div>

View file

@ -18,13 +18,13 @@ defmodule ToweropsWeb.AlertLive.Index do
{:ok,
socket
|> assign(:page_title, "Alerts")
|> assign(:filter, "active")
|> assign(:filter, "all")
|> load_alerts(organization.id)}
end
@impl true
def handle_params(params, _url, socket) do
filter = Map.get(params, "filter", "active")
filter = Map.get(params, "filter", "all")
{:noreply,
socket
@ -50,12 +50,12 @@ defmodule ToweropsWeb.AlertLive.Index do
end
@impl true
def handle_info({:new_alert, _equipment_id, _alert_type}, socket) do
def handle_info({:new_alert, _device_id, _alert_type}, socket) do
{:noreply, load_alerts(socket, socket.assigns.current_organization.id)}
end
@impl true
def handle_info({:alert_resolved, _equipment_id, _alert_type}, socket) do
def handle_info({:alert_resolved, _device_id, _alert_type}, socket) do
{:noreply, load_alerts(socket, socket.assigns.current_organization.id)}
end

View file

@ -56,9 +56,9 @@
<div class={[
"rounded-lg border p-6 shadow-sm",
alert.resolved_at && "opacity-60",
alert.alert_type == :equipment_down && is_nil(alert.resolved_at) &&
alert.alert_type == :device_down && is_nil(alert.resolved_at) &&
"border-l-4 border-red-500 bg-red-50 dark:bg-red-950 dark:border-red-700",
(alert.alert_type != :equipment_down || alert.resolved_at) &&
(alert.alert_type != :device_down || alert.resolved_at) &&
"border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
]}>
<div class="flex items-start justify-between">
@ -66,16 +66,16 @@
<div class="flex items-center gap-3 flex-wrap">
<span class={[
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium",
alert.alert_type == :equipment_down &&
alert.alert_type == :device_down &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
alert.alert_type == :equipment_up &&
alert.alert_type == :device_up &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
]}>
<%= case alert.alert_type do %>
<% :equipment_down -> %>
<.icon name="hero-exclamation-triangle" class="h-4 w-4" /> Equipment Down
<% :equipment_up -> %>
<.icon name="hero-check-circle" class="h-4 w-4" /> Equipment Recovered
<% :device_down -> %>
<.icon name="hero-exclamation-triangle" class="h-4 w-4" /> Device Down
<% :device_up -> %>
<.icon name="hero-check-circle" class="h-4 w-4" /> Device Recovered
<% end %>
</span>
@ -94,12 +94,10 @@
<h3 class="mt-3 font-semibold text-zinc-900 dark:text-zinc-100">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{alert.equipment.id}"
}
navigate={~p"/orgs/#{@current_organization.slug}/devices/#{alert.device.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{alert.equipment.name}
{alert.device.name}
</.link>
</h3>
@ -132,18 +130,18 @@
<strong class="font-medium">Site:</strong>
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/sites/#{alert.equipment.site.id}"
~p"/orgs/#{@current_organization.slug}/sites/#{alert.device.site.id}"
}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{alert.equipment.site.name}
{alert.device.site.name}
</.link>
</div>
</div>
</div>
<div class="ml-4">
<%= if alert.alert_type == :equipment_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
<%= if alert.alert_type == :device_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
<.button phx-click="acknowledge" phx-value-id={alert.id} variant="primary">
<.icon name="hero-check" class="h-4 w-4" /> Acknowledge
</.button>

View file

@ -3,7 +3,7 @@ defmodule ToweropsWeb.DashboardLive do
use ToweropsWeb, :live_view
alias Towerops.Alerts
alias Towerops.Equipment
alias Towerops.Devices
alias Towerops.Sites
@impl true
@ -24,31 +24,31 @@ defmodule ToweropsWeb.DashboardLive do
end
@impl true
def handle_info({:new_alert, _equipment_id, _alert_type}, socket) do
def handle_info({:new_alert, _device_id, _alert_type}, socket) do
{:noreply, load_dashboard_data(socket, socket.assigns.current_organization.id)}
end
@impl true
def handle_info({:alert_resolved, _equipment_id, _alert_type}, socket) do
def handle_info({:alert_resolved, _device_id, _alert_type}, socket) do
{:noreply, load_dashboard_data(socket, socket.assigns.current_organization.id)}
end
defp load_dashboard_data(socket, organization_id) do
active_alerts = Alerts.list_organization_active_alerts(organization_id)
sites_count = length(Sites.list_organization_sites(organization_id))
equipment = Equipment.list_organization_equipment(organization_id)
devices = Devices.list_organization_devices(organization_id)
equipment_by_status =
equipment
devices_by_status =
devices
|> Enum.group_by(& &1.status)
|> Map.new(fn {status, equip} -> {status, length(equip)} end)
socket
|> assign(:active_alerts, active_alerts)
|> assign(:sites_count, sites_count)
|> assign(:equipment_count, length(equipment))
|> assign(:equipment_up, Map.get(equipment_by_status, :up, 0))
|> assign(:equipment_down, Map.get(equipment_by_status, :down, 0))
|> assign(:equipment_unknown, Map.get(equipment_by_status, :unknown, 0))
|> assign(:device_count, length(devices))
|> assign(:device_up, Map.get(devices_by_status, :up, 0))
|> assign(:device_down, Map.get(devices_by_status, :down, 0))
|> assign(:device_unknown, Map.get(devices_by_status, :unknown, 0))
end
end

View file

@ -39,7 +39,7 @@
2
</div>
<div>
<h4 class="font-medium text-zinc-900 dark:text-zinc-100">Add Equipment</h4>
<h4 class="font-medium text-zinc-900 dark:text-zinc-100">Add Device</h4>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Add network devices (routers, switches, servers) to your sites
</p>
@ -54,7 +54,7 @@
Monitor & Receive Alerts
</h4>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Towerops will automatically monitor your equipment and alert you of issues
Towerops will automatically monitor your devices and alert you of issues
</p>
</div>
</div>
@ -78,23 +78,23 @@
</.link>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment"}
navigate={~p"/orgs/#{@current_organization.slug}/devices"}
class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900"
>
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Equipment</h3>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-zinc-100">{@equipment_count}</p>
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Device</h3>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-zinc-100">{@device_count}</p>
<div class="mt-3 space-y-1.5 text-sm">
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{@equipment_up} Up
{@device_up} Up
</div>
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
{@equipment_down} Down
{@device_down} Down
</div>
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-zinc-400"></span>
{@equipment_unknown} Unknown
{@device_unknown} Unknown
</div>
</div>
</.link>
@ -118,15 +118,15 @@
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">System Status</h3>
<div class={[
"mt-3 flex items-center gap-2 text-sm font-semibold",
(@equipment_down == 0 && "text-green-600 dark:text-green-500") ||
(@device_down == 0 && "text-green-600 dark:text-green-500") ||
"text-amber-600 dark:text-amber-500"
]}>
<%= if @equipment_down == 0 do %>
<%= if @device_down == 0 do %>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span>All Systems Operational</span>
<% else %>
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
<span>{@equipment_down} Equipment Down</span>
<span>{@device_down} Device Down</span>
<% end %>
</div>
</div>
@ -154,12 +154,10 @@
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{alert.equipment.id}"
}
navigate={~p"/orgs/#{@current_organization.slug}/devices/#{alert.device.id}"}
class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
>
{alert.equipment.name}
{alert.device.name}
</.link>
</h3>
<p class="mt-0.5 text-sm text-zinc-700 dark:text-zinc-300">{alert.message}</p>
@ -190,11 +188,11 @@
<span>Manage Sites</span>
</.link>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment"}
navigate={~p"/orgs/#{@current_organization.slug}/devices"}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<.icon name="hero-server" class="h-5 w-5" />
<span>Manage Equipment</span>
<span>Manage Device</span>
</.link>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}

View file

@ -1,10 +1,10 @@
defmodule ToweropsWeb.EquipmentLive.Form do
defmodule ToweropsWeb.DeviceLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Agents
alias Towerops.Equipment
alias Towerops.Equipment.Equipment, as: EquipmentSchema
alias Towerops.Devices
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Sites
alias Towerops.Snmp
@ -18,7 +18,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do
if Enum.empty?(sites) do
{:ok,
socket
|> put_flash(:info, "Please create a site before adding equipment.")
|> put_flash(:info, "Please create a site before adding a device.")
|> push_navigate(to: ~p"/orgs/#{organization.slug}/sites/new")}
else
{:ok,
@ -61,27 +61,27 @@ defmodule ToweropsWeb.EquipmentLive.Form do
equipment_attrs
end
changeset = Equipment.change_equipment(%EquipmentSchema{}, equipment_attrs)
changeset = Devices.change_device(%DeviceSchema{}, equipment_attrs)
socket
|> assign(:page_title, "New Equipment")
|> assign(:equipment, %EquipmentSchema{})
|> assign(:page_title, "New Device")
|> assign(:device, %DeviceSchema{})
|> assign(:form, to_form(changeset))
end
defp apply_action(socket, :edit, %{"id" => id}) do
equipment = Equipment.get_equipment!(id)
device = Devices.get_device!(id)
# Preload necessary associations for agent resolution
equipment = Towerops.Repo.preload(equipment, site: [organization: :default_agent_token])
device = Towerops.Repo.preload(device, site: [organization: :default_agent_token])
# Get current agent assignment if any
current_assignment = Agents.get_equipment_assignment(equipment.id)
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(equipment)
{effective_agent_id, agent_source} = Agents.get_effective_agent_token_with_source(device)
# For the form, we only want the equipment-specific assignment
# For the form, we only want the device-specific assignment
agent_token_id =
if current_assignment do
current_assignment.agent_token_id
@ -95,15 +95,15 @@ defmodule ToweropsWeb.EquipmentLive.Form do
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, %{})
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 = Equipment.get_snmp_config(equipment)
snmp_config = Devices.get_snmp_config(device)
socket
|> assign(:page_title, "Edit Equipment")
|> assign(:equipment, equipment_with_agent)
|> 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)
@ -113,31 +113,31 @@ defmodule ToweropsWeb.EquipmentLive.Form do
end
@impl true
def handle_event("validate", %{"equipment" => equipment_params}, socket) do
def handle_event("validate", %{"device" => device_params}, socket) do
changeset =
socket.assigns.equipment
|> Equipment.change_equipment(equipment_params)
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", %{"equipment" => equipment_params}, socket) do
save_equipment(socket, socket.assigns.live_action, equipment_params)
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 Equipment.delete_equipment(socket.assigns.equipment) do
case Devices.delete_device(socket.assigns.device) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Equipment deleted successfully")
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/equipment")}
|> 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 equipment")}
{:noreply, put_flash(socket, :error, "Unable to delete device")}
end
end
@ -151,96 +151,96 @@ defmodule ToweropsWeb.EquipmentLive.Form do
@impl true
def handle_event("trigger_discovery", _params, socket) do
equipment = socket.assigns.equipment
device = socket.assigns.device
if equipment.snmp_enabled do
if device.snmp_enabled do
# Run discovery in background
_ =
Task.start(fn ->
Snmp.discover_equipment(equipment)
Snmp.discover_equipment(device)
end)
{:noreply, put_flash(socket, :info, "Discovery started...")}
else
{:noreply, put_flash(socket, :error, "SNMP is not enabled for this equipment")}
{:noreply, put_flash(socket, :error, "SNMP is not enabled for this device")}
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")
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")
case Equipment.create_equipment(equipment_params) do
{:ok, equipment} ->
# Handle agent assignment after equipment creation
handle_agent_assignment(equipment.id, agent_token_id)
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_equipment_creation(equipment)
flash_message = handle_device_creation(device)
{:noreply,
socket
|> put_flash(:info, flash_message)
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/equipment/#{equipment.id}")}
|> 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 save_equipment(socket, :edit, equipment_params) do
old_equipment = socket.assigns.equipment
defp save_device(socket, :edit, device_params) do
old_device = socket.assigns.device
# 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")
# 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")
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)
case Devices.update_device(old_device, device_params) do
{:ok, device} ->
# device update
handle_agent_assignment(device.id, agent_token_id)
flash_message = handle_equipment_update(old_equipment, equipment)
flash_message = handle_device_update(old_device, device)
{:noreply,
socket
|> put_flash(:info, flash_message)
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/equipment/#{equipment.id}")}
|> 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_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."
defp handle_device_creation(device) do
if device.snmp_enabled do
_ = Task.start(fn -> Snmp.discover_equipment(device) end)
"Device created successfully. SNMP discovery started in background."
else
"Equipment created successfully"
"Device 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."
defp handle_device_update(old_device, device) do
if should_trigger_snmp_discovery?(old_device, device) do
_ = Task.start(fn -> Snmp.discover_equipment(device) end)
"Device updated successfully. SNMP discovery started in background."
else
"Equipment updated successfully"
"Device 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))
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_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
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()) :: %{
@ -328,19 +328,19 @@ defmodule ToweropsWeb.EquipmentLive.Form do
end
end
defp handle_agent_assignment(equipment_id, agent_token_id) when is_binary(agent_token_id) do
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_equipment_assignment(equipment_id, nil)
Agents.update_device_assignment(device_id, nil)
else
Agents.update_equipment_assignment(equipment_id, agent_token_id)
Agents.update_device_assignment(device_id, agent_token_id)
end
end
defp handle_agent_assignment(equipment_id, nil) do
defp handle_agent_assignment(device_id, nil) do
# No agent selected, remove any assignment
Agents.update_equipment_assignment(equipment_id, nil)
Agents.update_device_assignment(device_id, nil)
end
defp handle_agent_assignment(_equipment_id, _), do: :ok
defp handle_agent_assignment(_device_id, _), do: :ok
end

View file

@ -7,13 +7,13 @@
<.link
navigate={
if @live_action == :edit,
do: ~p"/orgs/#{@organization.slug}/equipment/#{@equipment.id}",
else: ~p"/orgs/#{@organization.slug}/equipment"
do: ~p"/orgs/#{@organization.slug}/devices/#{@device.id}",
else: ~p"/orgs/#{@organization.slug}/devices"
}
class="inline-flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
<.icon name="hero-arrow-left" class="h-4 w-4" />
{if @live_action == :edit, do: "Back to Equipment", else: "Back to Equipment List"}
{if @live_action == :edit, do: "Back to Device", else: "Back to Device List"}
</.link>
</div>
@ -21,14 +21,14 @@
{@page_title}
<:subtitle>
{if @live_action == :new,
do: "Add new equipment to monitor",
else: "Update equipment details"}
do: "Add new device to monitor",
else: "Update device details"}
</:subtitle>
</.header>
<div class="max-w-2xl">
<.form for={@form} id="equipment-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label="Equipment Name" required />
<.form for={@form} id="device-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label="Device Name" required />
<.input field={@form[:ip_address]} type="text" label="IP Address" required />
@ -63,11 +63,11 @@
/>
<%= if @live_action == :edit and Map.has_key?(assigns, :agent_source) do %>
<%= case @agent_source do %>
<% :equipment -> %>
<% :device -> %>
<p class="mt-1 text-sm text-amber-600 dark:text-amber-400 flex items-center gap-1">
<.icon name="hero-exclamation-triangle" class="h-4 w-4" />
<strong>Overriding</strong> site/organization defaults -
this equipment is explicitly assigned
this device is explicitly assigned
</p>
<% :site -> %>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
@ -86,7 +86,7 @@
<% end %>
<% else %>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Assign this equipment to a remote agent for local SNMP polling. Leave empty to inherit from site or organization defaults.
Assign this device to a remote agent for local SNMP polling. Leave empty to inherit from site or organization defaults.
</p>
<% end %>
<% end %>
@ -121,7 +121,7 @@
<%= if @live_action == :edit and Map.has_key?(assigns, :snmp_config_source) do %>
<%= case @snmp_config_source do %>
<% :equipment -> %>
<% :device -> %>
<p class="text-sm text-amber-600 dark:text-amber-400 flex items-center gap-1">
<.icon name="hero-exclamation-triangle" class="h-4 w-4" />
<strong>Overriding</strong> site/organization SNMP defaults
@ -187,8 +187,8 @@
</div>
<div class="flex gap-3 mt-6">
<.button phx-disable-with="Saving..." variant="primary">Save Equipment</.button>
<.button navigate={~p"/orgs/#{@organization.slug}/equipment"}>Cancel</.button>
<.button phx-disable-with="Saving..." variant="primary">Save Device</.button>
<.button navigate={~p"/orgs/#{@organization.slug}/devices"}>Cancel</.button>
</div>
</.form>
@ -196,14 +196,14 @@
<div class="mt-12 pt-8 border-t border-zinc-200 dark:border-zinc-800">
<h3 class="text-lg font-semibold text-red-600 dark:text-red-500 mb-4">Danger Zone</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
Once you delete this equipment, there is no going back. All monitoring history and alerts will be permanently deleted.
Once you delete this device, there is no going back. All monitoring history and alerts will be permanently deleted.
</p>
<.button
phx-click="delete"
data-confirm="Are you sure you want to delete this equipment? All monitoring history and alerts will be permanently deleted."
data-confirm="Are you sure you want to delete this device? All monitoring history and alerts will be permanently deleted."
variant="danger"
>
<.icon name="hero-trash" class="h-4 w-4" /> Delete Equipment
<.icon name="hero-trash" class="h-4 w-4" /> Delete Device
</.button>
</div>
<% end %>

View file

@ -1,20 +1,20 @@
defmodule ToweropsWeb.EquipmentLive.Index do
defmodule ToweropsWeb.DeviceLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Equipment
alias Towerops.Devices
alias Towerops.Sites
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
equipment = Equipment.list_organization_equipment(organization.id)
device = Devices.list_organization_devices(organization.id)
sites = Sites.list_organization_sites(organization.id)
{:ok,
socket
|> assign(:page_title, "Equipment")
|> assign(:equipment, equipment)
|> assign(:page_title, "Devices")
|> assign(:device, device)
|> assign(:has_sites, sites != [])}
end

View file

@ -2,18 +2,18 @@
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="equipment"
active_page="devices"
>
<.header>
{@page_title}
<:subtitle>Monitor and manage all your network equipment</:subtitle>
<:subtitle>Monitor and manage all your network devices</:subtitle>
<:actions>
<.button
:if={@has_sites}
navigate={~p"/orgs/#{@current_organization.slug}/equipment/new"}
navigate={~p"/orgs/#{@current_organization.slug}/devices/new"}
variant="primary"
>
<.icon name="hero-plus" class="h-5 w-5" /> New Equipment
<.icon name="hero-plus" class="h-5 w-5" /> New Device
</.button>
</:actions>
</.header>
@ -28,10 +28,10 @@
Create a site first
</h3>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Before adding equipment, you need to create at least one site location.
Before adding device, you need to create at least one site location.
</p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Sites help you organize your equipment by physical location.
Sites help you organize your devices by physical location.
</p>
<div class="mt-6">
<.button navigate={~p"/orgs/#{@current_organization.slug}/sites/new"} variant="primary">
@ -40,29 +40,29 @@
</div>
</div>
<% else %>
<%= if @equipment == [] do %>
<%= if @device == [] do %>
<div class="text-center py-16">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" />
<h3 class="mt-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">No equipment</h3>
<h3 class="mt-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">No devices</h3>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Get started by adding your first equipment.
Get started by adding your first device.
</p>
<div class="mt-6">
<.button
navigate={~p"/orgs/#{@current_organization.slug}/equipment/new"}
navigate={~p"/orgs/#{@current_organization.slug}/devices/new"}
variant="primary"
>
<.icon name="hero-plus" class="h-5 w-5" /> New Equipment
<.icon name="hero-plus" class="h-5 w-5" /> New Device
</.button>
</div>
</div>
<% else %>
<.table
id="equipment"
rows={@equipment}
id="devices"
rows={@device}
row_click={
fn eq ->
JS.navigate(~p"/orgs/#{@current_organization.slug}/equipment/#{eq.id}")
JS.navigate(~p"/orgs/#{@current_organization.slug}/devices/#{eq.id}")
end
}
>

View file

@ -1,8 +1,8 @@
defmodule ToweropsWeb.EquipmentLive.Show do
defmodule ToweropsWeb.DeviceLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Equipment
alias Towerops.Devices
alias Towerops.Monitoring
alias Towerops.Snmp
@ -15,7 +15,7 @@ defmodule ToweropsWeb.EquipmentLive.Show do
def handle_params(%{"id" => id} = params, _, socket) do
_ =
if connected?(socket) do
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{id}")
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{id}")
Process.send_after(self(), :refresh_data, 10_000)
end
@ -30,34 +30,34 @@ defmodule ToweropsWeb.EquipmentLive.Show do
@impl true
def handle_info(:refresh_data, socket) do
Process.send_after(self(), :refresh_data, 10_000)
{:noreply, load_equipment_data(socket, socket.assigns.equipment.id)}
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:equipment_status_changed, _equipment_id, _new_status, _response_time}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.equipment.id)}
def handle_info({:device_status_changed, _device_id, _new_status, _response_time}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:discovery_completed, _equipment_id}, socket) do
def handle_info({:discovery_completed, _device_id}, socket) do
{:noreply,
socket
|> load_equipment_data(socket.assigns.equipment.id)
|> load_equipment_data(socket.assigns.device.id)
|> put_flash(:info, "Discovery completed")}
end
# Private functions
defp load_equipment_data(socket, equipment_id) do
equipment = Equipment.get_equipment!(equipment_id)
recent_checks = Monitoring.list_equipment_checks(equipment_id, 50)
snmp_data = load_snmp_data(equipment_id)
events = Equipment.list_equipment_events(equipment_id, 100)
neighbors = Snmp.list_neighbors_with_equipment(equipment_id)
cpu_chart_data = load_sensor_chart_data(equipment_id, ["cpu_load"])
memory_chart_data = load_sensor_chart_data(equipment_id, ["memory_usage"])
storage_chart_data = load_sensor_chart_data(equipment_id, ["disk_usage"])
traffic_chart_data = load_overall_traffic_chart_data(equipment_id)
defp load_equipment_data(socket, device_id) do
device = Devices.get_device!(device_id)
recent_checks = Monitoring.list_devices_checks(device_id, 50)
snmp_data = load_snmp_data(device_id)
events = Devices.list_devices_events(device_id, 100)
neighbors = Snmp.list_neighbors_with_equipment(device_id)
cpu_chart_data = load_sensor_chart_data(device_id, ["cpu_load"])
memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"])
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
traffic_chart_data = load_overall_traffic_chart_data(device_id)
# Filter sensors by type for temperature, voltage, and storage
temperature_sensors =
@ -68,14 +68,14 @@ defmodule ToweropsWeb.EquipmentLive.Show do
storage_sensors = Enum.filter(snmp_data.sensors, &(&1.sensor_type in ["disk_usage"]))
# Calculate metrics for dashboard
metrics = calculate_metrics(recent_checks, equipment)
metrics = calculate_metrics(recent_checks, device)
# Group interfaces by type for the ports tab
interfaces_by_type = group_interfaces_by_type(snmp_data.interfaces)
socket
|> assign(:page_title, equipment.name)
|> assign(:equipment, equipment)
|> assign(:page_title, device.name)
|> assign(:device, device)
|> assign(:recent_checks, recent_checks)
|> assign(:metrics, metrics)
|> assign(:snmp_device, snmp_data.device)
@ -216,8 +216,8 @@ defmodule ToweropsWeb.EquipmentLive.Show do
|> String.capitalize()
end
defp load_snmp_data(equipment_id) do
case Snmp.get_device_with_associations(equipment_id) do
defp load_snmp_data(device_id) do
case Snmp.get_device_with_associations(device_id) do
nil ->
%{device: nil, interfaces: [], sensors: []}
@ -250,8 +250,8 @@ defmodule ToweropsWeb.EquipmentLive.Show do
end
end
defp load_sensor_chart_data(equipment_id, sensor_types) when is_list(sensor_types) do
case Snmp.get_device_with_associations(equipment_id) do
defp load_sensor_chart_data(device_id, sensor_types) when is_list(sensor_types) do
case Snmp.get_device_with_associations(device_id) do
nil -> nil
device -> build_sensor_datasets_json(device, sensor_types)
end
@ -291,8 +291,8 @@ defmodule ToweropsWeb.EquipmentLive.Show do
}
end
defp load_overall_traffic_chart_data(equipment_id) do
case Snmp.get_device_with_associations(equipment_id) do
defp load_overall_traffic_chart_data(device_id) do
case Snmp.get_device_with_associations(device_id) do
nil -> nil
device -> build_overall_traffic_chart_json(device)
end

View file

@ -2,7 +2,7 @@
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="equipment"
active_page="devices"
>
<div class="mb-6">
<!-- Breadcrumbs -->
@ -10,16 +10,16 @@
<ol class="inline-flex items-center space-x-1 text-sm text-zinc-600 dark:text-zinc-400">
<li class="inline-flex items-center">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{@equipment.site.id}"}
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{@device.site.id}"}
class="hover:text-zinc-900 dark:hover:text-zinc-200"
>
{@equipment.site.name}
{@device.site.name}
</.link>
</li>
<li aria-current="page">
<div class="flex items-center">
<.icon name="hero-chevron-right" class="h-4 w-4 mx-1" />
<span class="text-zinc-900 dark:text-zinc-100 font-medium">{@equipment.name}</span>
<span class="text-zinc-900 dark:text-zinc-100 font-medium">{@device.name}</span>
</div>
</li>
</ol>
@ -27,13 +27,11 @@
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">{@equipment.name}</h1>
<p class="text-sm text-zinc-600 dark:text-zinc-400 font-mono">{@equipment.ip_address}</p>
<h1 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">{@device.name}</h1>
<p class="text-sm text-zinc-600 dark:text-zinc-400 font-mono">{@device.ip_address}</p>
</div>
<div class="flex gap-2">
<.button navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/edit"
}>
<.button navigate={~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}/edit"}>
<.icon name="hero-pencil" class="h-4 w-4" /> Edit
</.button>
</div>
@ -42,7 +40,7 @@
<div class="border-b border-zinc-200 dark:border-zinc-700">
<nav class="-mb-px flex space-x-8">
<.link
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=overview"}
patch={~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}?tab=overview"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "overview" do
@ -57,7 +55,7 @@
<%= if @snmp_device do %>
<.link
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=ports"}
patch={~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}?tab=ports"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "ports" do
@ -72,7 +70,7 @@
<% end %>
<.link
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=neighbors"}
patch={~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}?tab=neighbors"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "neighbors" do
@ -86,7 +84,7 @@
</.link>
<.link
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=logs"}
patch={~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}?tab=logs"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "logs" do
@ -121,14 +119,14 @@
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">System Name</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@snmp_device.sys_name || @equipment.name}
{@snmp_device.sys_name || @device.name}
</dd>
</div>
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Resolved IP</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
{@equipment.ip_address}
{@device.ip_address}
</dd>
</div>
@ -181,14 +179,14 @@
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Name</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@equipment.name}
{@device.name}
</dd>
</div>
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">IP Address</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
{@equipment.ip_address}
{@device.ip_address}
</dd>
</div>
<% end %>
@ -196,15 +194,15 @@
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Device Added</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{format_device_age(@equipment.inserted_at)}
{format_device_age(@device.inserted_at)}
</dd>
</div>
<div class="flex justify-between py-1.5">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Last Discovered</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{if @equipment.last_discovery_at do
format_device_age(@equipment.last_discovery_at)
{if @device.last_discovery_at do
format_device_age(@device.last_discovery_at)
else
"Never"
end}
@ -218,7 +216,7 @@
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/traffic"
~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}/graph/traffic"
}
class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors"
>
@ -252,7 +250,7 @@
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/processors"
~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}/graph/processors"
}
class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors"
>
@ -280,7 +278,7 @@
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/memory"
~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}/graph/memory"
}
class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors"
>
@ -318,7 +316,7 @@
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/storage"
~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}/graph/storage"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
@ -357,7 +355,7 @@
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/temperature"
~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}/graph/temperature"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
@ -394,7 +392,7 @@
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/voltage"
~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}/graph/voltage"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
@ -453,7 +451,7 @@
<td class="px-4 py-3">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/traffic?interface_id=#{interface.id}"
~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}/graph/traffic?interface_id=#{interface.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400"
>
@ -538,14 +536,14 @@
{neighbor.interface.if_name || neighbor.interface.if_descr}
</td>
<td class="px-4 py-3">
<%= if neighbor.matched_equipment do %>
<%= if neighbor.matched_device do %>
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{neighbor.matched_equipment.id}"
~p"/orgs/#{@current_organization.slug}/devices/#{neighbor.matched_device.id}"
}
class="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{neighbor.matched_equipment.name}
{neighbor.matched_device.name}
</.link>
<% else %>
<div class="text-zinc-900 dark:text-zinc-100">
@ -561,7 +559,7 @@
<div class="text-zinc-900 dark:text-zinc-100">
{neighbor.remote_port_id || "-"}
</div>
<%= if !neighbor.matched_equipment && neighbor.remote_chassis_id do %>
<%= if !neighbor.matched_device && neighbor.remote_chassis_id do %>
<div class="text-xs font-mono text-zinc-500 dark:text-zinc-400">
{neighbor.remote_chassis_id}
</div>

View file

@ -2,7 +2,7 @@ defmodule ToweropsWeb.GraphLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Equipment
alias Towerops.Devices
alias Towerops.Snmp
@impl true
@ -11,10 +11,10 @@ defmodule ToweropsWeb.GraphLive.Show do
end
@impl true
def handle_params(%{"id" => equipment_id, "sensor_type" => sensor_type} = params, _, socket) do
def handle_params(%{"id" => device_id, "sensor_type" => sensor_type} = params, _, socket) do
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{equipment_id}")
Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}")
end
range = Map.get(params, "range", "24h")
@ -22,7 +22,7 @@ defmodule ToweropsWeb.GraphLive.Show do
socket =
socket
|> assign(:equipment_id, equipment_id)
|> assign(:device_id, device_id)
|> assign(:sensor_type, sensor_type)
|> assign(:range, range)
|> assign(:interface_id, interface_id)
@ -38,7 +38,7 @@ defmodule ToweropsWeb.GraphLive.Show do
{:noreply,
push_patch(socket,
to:
~p"/orgs/#{socket.assigns.current_organization.slug}/equipment/#{socket.assigns.equipment_id}/graph/#{socket.assigns.sensor_type}?#{params}"
~p"/orgs/#{socket.assigns.current_organization.slug}/devices/#{socket.assigns.device_id}/graph/#{socket.assigns.sensor_type}?#{params}"
)}
end
@ -55,12 +55,12 @@ defmodule ToweropsWeb.GraphLive.Show do
# Private functions
defp load_graph_data(socket) do
equipment_id = socket.assigns.equipment_id
device_id = socket.assigns.device_id
sensor_type = socket.assigns.sensor_type
range = socket.assigns.range
interface_id = socket.assigns.interface_id
equipment = Equipment.get_equipment!(equipment_id)
device = Devices.get_device!(device_id)
{chart_data, title_suffix} =
cond do
@ -68,11 +68,11 @@ defmodule ToweropsWeb.GraphLive.Show do
{load_interface_traffic_chart_data(interface_id, range), get_interface_name(interface_id)}
sensor_type == "traffic" ->
{load_traffic_chart_data(equipment_id, range), nil}
{load_traffic_chart_data(device_id, range), nil}
true ->
sensor_types = get_sensor_types_for_chart(sensor_type)
{load_sensor_chart_data(equipment_id, sensor_types, range), nil}
{load_sensor_chart_data(device_id, sensor_types, range), nil}
end
{title, unit, auto_scale} = get_chart_config(sensor_type)
@ -80,8 +80,8 @@ defmodule ToweropsWeb.GraphLive.Show do
show_zero_line = sensor_type == "traffic"
socket
|> assign(:equipment, equipment)
|> assign(:page_title, "#{equipment.name} - #{chart_title}")
|> assign(:device, device)
|> assign(:page_title, "#{device.name} - #{chart_title}")
|> assign(:chart_title, chart_title)
|> assign(:chart_data, chart_data)
|> assign(:unit, unit)
@ -105,8 +105,8 @@ defmodule ToweropsWeb.GraphLive.Show do
defp get_chart_config("traffic"), do: {"Overall Traffic", "bps", true}
defp get_chart_config(_), do: {"Sensor Data", "", false}
defp load_sensor_chart_data(equipment_id, sensor_types, range) do
case Snmp.get_device_with_associations(equipment_id) do
defp load_sensor_chart_data(device_id, sensor_types, range) do
case Snmp.get_device_with_associations(device_id) do
nil ->
nil
@ -166,8 +166,8 @@ defmodule ToweropsWeb.GraphLive.Show do
defp get_limit_for_range("30d"), do: 43_200
defp get_limit_for_range(_), do: 2880
defp load_traffic_chart_data(equipment_id, range) do
case Snmp.get_device_with_associations(equipment_id) do
defp load_traffic_chart_data(device_id, range) do
case Snmp.get_device_with_associations(device_id) do
nil -> nil
device -> build_traffic_chart_json(device, range)
end

View file

@ -2,21 +2,21 @@
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="equipment"
active_page="devices"
>
<div class="mb-6">
<!-- Header with back button -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment_id}"}
navigate={~p"/orgs/#{@organization.slug}/devices/#{@device_id}"}
class="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
<.icon name="hero-arrow-left" class="h-5 w-5" />
</.link>
<div>
<h1 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">{@chart_title}</h1>
<p class="text-sm text-zinc-600 dark:text-zinc-400">{@equipment.name}</p>
<p class="text-sm text-zinc-600 dark:text-zinc-400">{@device.name}</p>
</div>
</div>
</div>

View file

@ -39,8 +39,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
{:ok, organization} ->
{:noreply,
socket
|> assign(:organization, organization)
|> put_flash(:info, "Organization settings updated successfully")}
|> put_flash(:info, "Organization settings updated successfully")
|> push_navigate(to: ~p"/orgs/#{organization.slug}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
@ -51,13 +51,13 @@ defmodule ToweropsWeb.Org.SettingsLive do
def handle_event("apply_snmp_to_all", _params, socket) do
{count, _} = Organizations.apply_snmp_config_to_all_equipment(socket.assigns.organization.id)
{:noreply, put_flash(socket, :info, "Applied SNMP configuration to #{count} equipment records across all sites")}
{:noreply, put_flash(socket, :info, "Applied SNMP configuration to #{count} device records across all sites")}
end
@impl true
def handle_event("apply_agent_to_all", _params, socket) do
{count, _} = Organizations.apply_agent_to_all_equipment(socket.assigns.organization.id)
{:noreply, put_flash(socket, :info, "Applied default agent to #{count} equipment records across all sites")}
{:noreply, put_flash(socket, :info, "Applied default agent to #{count} device records across all sites")}
end
end

View file

@ -26,7 +26,7 @@
<div class="mt-6 border-t pt-6">
<h3 class="text-lg font-medium mb-4">SNMP Configuration</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
Set default SNMP settings for all devices in this organization. These can be overridden at the site or equipment level.
Set default SNMP settings for all devices in this organization. These can be overridden at the site or device level.
</p>
<.input
@ -46,24 +46,24 @@
<p class="mt-3 text-sm text-zinc-500 dark:text-zinc-400 italic">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
SNMP configuration hierarchy: Equipment > Site > Organization
SNMP configuration hierarchy: Device > Site > Organization
</p>
<%= if @organization.snmp_community do %>
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
Force Apply to All Equipment
Force Apply to All Device
</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
This will override SNMP settings for ALL equipment across all sites in this organization.
This will override SNMP settings for ALL devices across all sites in this organization.
</p>
<.button
type="button"
phx-click="apply_snmp_to_all"
data-confirm="This will replace SNMP settings for ALL equipment in this organization. Are you sure?"
data-confirm="This will replace SNMP settings for ALL devices in this organization. Are you sure?"
variant="danger"
>
<.icon name="hero-arrow-path" class="h-4 w-4" /> Apply SNMP Config to All Equipment
<.icon name="hero-arrow-path" class="h-4 w-4" /> Apply SNMP Config to All Device
</.button>
</div>
<% end %>
@ -73,7 +73,7 @@
<div class="mt-6 border-t pt-6">
<h3 class="text-lg font-medium mb-4">Default Agent</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
Select a default agent for SNMP polling. This will be used for all equipment in this organization unless overridden at the site or equipment level.
Select a default agent for SNMP polling. This will be used for all devices in this organization unless overridden at the site or device level.
</p>
<.input
@ -86,13 +86,13 @@
<div class="mt-3 p-3 bg-zinc-50 dark:bg-zinc-800 rounded-lg text-sm">
<p class="font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Current Equipment Assignment:
Current Device Assignment:
</p>
<div class="space-y-1 text-zinc-600 dark:text-zinc-400">
<div class="flex items-center gap-2">
<.icon name="hero-server" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.direct}</strong> equipment-level override
<strong>{@assignment_breakdown.direct}</strong> device-level override
</span>
</div>
<div class="flex items-center gap-2">
@ -117,25 +117,24 @@
<p class="mt-3 text-sm text-zinc-500 dark:text-zinc-400 italic">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
Agent assignment hierarchy: Equipment > Site > Organization
Agent assignment hierarchy: Device > Site > Organization
</p>
<%= if @organization.default_agent_token_id do %>
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
Force Apply to All Equipment
Force Apply to All Device
</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
This will assign the default agent to ALL equipment across all sites in this organization.
This will assign the default agent to ALL devices across all sites in this organization.
</p>
<.button
type="button"
phx-click="apply_agent_to_all"
data-confirm="This will replace agent assignments for ALL equipment in this organization. Are you sure?"
data-confirm="This will replace agent assignments for ALL devices in this organization. Are you sure?"
variant="danger"
>
<.icon name="hero-arrow-path" class="h-4 w-4" />
Apply Default Agent to All Equipment
<.icon name="hero-arrow-path" class="h-4 w-4" /> Apply Default Agent to All Device
</.button>
</div>
<% end %>

View file

@ -1,7 +1,7 @@
<Layouts.authenticated flash={@flash} current_scope={@current_scope} current_organization={nil}>
<.header>
{@page_title}
<:subtitle>Create a new organization to manage your sites and equipment</:subtitle>
<:subtitle>Create a new organization to manage your sites and devices</:subtitle>
</.header>
<div class="max-w-2xl">

View file

@ -97,14 +97,14 @@ defmodule ToweropsWeb.SiteLive.Form do
def handle_event("apply_snmp_to_all", _params, socket) do
{count, _} = Sites.apply_snmp_config_to_all_equipment(socket.assigns.site.id)
{:noreply, put_flash(socket, :info, "Applied SNMP configuration to #{count} equipment records at this site")}
{:noreply, put_flash(socket, :info, "Applied SNMP configuration to #{count} device records at this site")}
end
@impl true
def handle_event("apply_agent_to_all", _params, socket) do
{count, _} = Sites.apply_agent_to_all_equipment(socket.assigns.site.id)
{:noreply, put_flash(socket, :info, "Applied agent to #{count} equipment records at this site")}
{:noreply, put_flash(socket, :info, "Applied agent to #{count} device records at this site")}
end
defp save_site(socket, :new, site_params) do

View file

@ -7,8 +7,8 @@
<.link
navigate={
if @live_action == :edit,
do: ~p"/orgs/#{@organization.slug}/sites/#{@site.id}",
else: ~p"/orgs/#{@organization.slug}/sites"
do: ~p"/orgs/#{@current_organization.slug}/sites/#{@site.id}",
else: ~p"/orgs/#{@current_organization.slug}/sites"
}
class="inline-flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
@ -86,7 +86,7 @@
<span class="text-zinc-400 dark:text-zinc-500 font-normal">(optional)</span>
</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
Set a default agent for SNMP polling at this site. This will override the organization default for all equipment at this site. Leave blank to inherit from organization.
Set a default agent for SNMP polling at this site. This will override the organization default for all devices at this site. Leave blank to inherit from organization.
</p>
<.input
@ -101,23 +101,23 @@
<p class="mt-1 text-sm text-amber-600 dark:text-amber-400 flex items-center gap-1">
<.icon name="hero-exclamation-triangle" class="h-4 w-4" />
<strong>Overriding</strong> organization default -
all equipment at this site will use this agent
all devices at this site will use this agent
</p>
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
Force Apply to Site Equipment
Force Apply to Site Device
</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
This will assign this agent to ALL equipment at this site.
This will assign this agent to ALL devices at this site.
</p>
<.button
type="button"
phx-click="apply_agent_to_all"
data-confirm="This will replace agent assignments for ALL equipment at this site. Are you sure?"
data-confirm="This will replace agent assignments for ALL devices at this site. Are you sure?"
variant="danger"
>
<.icon name="hero-arrow-path" class="h-4 w-4" /> Apply Agent to All Equipment
<.icon name="hero-arrow-path" class="h-4 w-4" /> Apply Agent to All Device
</.button>
</div>
<% else %>
@ -128,7 +128,7 @@
</p>
<% else %>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Set a default agent for all equipment at this site. Equipment can override this setting individually.
Set a default agent for all devices at this site. Device can override this setting individually.
</p>
<% end %>
<% end %>
@ -141,7 +141,7 @@
<span class="text-zinc-400 dark:text-zinc-500 font-normal">(optional)</span>
</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
Override organization SNMP defaults for all equipment at this site. Leave blank to inherit from organization.
Override organization SNMP defaults for all devices at this site. Leave blank to inherit from organization.
</p>
<.input
@ -162,18 +162,18 @@
<%= if @live_action == :edit and @site.snmp_community do %>
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
Force Apply to Site Equipment
Force Apply to Site Device
</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
This will override SNMP settings for ALL equipment at this site.
This will override SNMP settings for ALL devices at this site.
</p>
<.button
type="button"
phx-click="apply_snmp_to_all"
data-confirm="This will replace SNMP settings for ALL equipment at this site. Are you sure?"
data-confirm="This will replace SNMP settings for ALL devices at this site. Are you sure?"
variant="danger"
>
<.icon name="hero-arrow-path" class="h-4 w-4" /> Apply SNMP Config to All Equipment
<.icon name="hero-arrow-path" class="h-4 w-4" /> Apply SNMP Config to All Device
</.button>
</div>
<% end %>
@ -181,7 +181,7 @@
<div class="flex gap-3 mt-6">
<.button phx-disable-with="Saving..." variant="primary">Save Site</.button>
<.button navigate={~p"/orgs/#{@organization.slug}/sites"}>Cancel</.button>
<.button navigate={~p"/orgs/#{@current_organization.slug}/sites"}>Cancel</.button>
</div>
</.form>
@ -189,11 +189,11 @@
<div class="mt-12 pt-8 border-t border-zinc-200 dark:border-zinc-800">
<h3 class="text-lg font-semibold text-red-600 dark:text-red-500 mb-4">Danger Zone</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
Once you delete a site, there is no going back. This will also delete all equipment at this site.
Once you delete a site, there is no going back. This will also delete all devices at this site.
</p>
<.button
phx-click="delete"
data-confirm="Are you sure you want to delete this site? This will also delete all equipment at this site."
data-confirm="Are you sure you want to delete this site? This will also delete all devices at this site."
variant="danger"
>
<.icon name="hero-trash" class="h-4 w-4" /> Delete Site

View file

@ -2,7 +2,7 @@ defmodule ToweropsWeb.SiteLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Equipment
alias Towerops.Devices
alias Towerops.Sites
@impl true
@ -14,12 +14,12 @@ defmodule ToweropsWeb.SiteLive.Show do
def handle_params(%{"id" => id}, _, socket) do
organization = socket.assigns.current_organization
site = Sites.get_organization_site!(organization.id, id)
equipment = Equipment.list_site_equipment(site.id)
device = Devices.list_site_devices(site.id)
{:noreply,
socket
|> assign(:page_title, site.name)
|> assign(:site, site)
|> assign(:equipment, equipment)}
|> assign(:device, device)}
end
end

View file

@ -64,41 +64,41 @@
<div>
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Equipment</h3>
<h3 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Device</h3>
<.button
navigate={~p"/orgs/#{@current_organization.slug}/equipment/new?site_id=#{@site.id}"}
navigate={~p"/orgs/#{@current_organization.slug}/devices/new?site_id=#{@site.id}"}
variant="primary"
>
<.icon name="hero-plus" class="h-4 w-4" /> Add Equipment
<.icon name="hero-plus" class="h-4 w-4" /> Add Device
</.button>
</div>
<%= if @equipment == [] do %>
<%= if @device == [] do %>
<div class="rounded-lg border-2 border-dashed border-zinc-300 bg-zinc-50 p-8 text-center dark:border-zinc-700 dark:bg-zinc-900">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" />
<h4 class="mt-4 text-base font-semibold text-zinc-900 dark:text-zinc-100">
Add your first device
</h4>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Start monitoring by adding network equipment to this site.
Start monitoring by adding network devices to this site.
</p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
You can add routers, switches, servers, or any device with an IP address.
</p>
<div class="mt-6">
<.button
navigate={~p"/orgs/#{@current_organization.slug}/equipment/new?site_id=#{@site.id}"}
navigate={~p"/orgs/#{@current_organization.slug}/devices/new?site_id=#{@site.id}"}
variant="primary"
>
<.icon name="hero-plus" class="h-5 w-5" /> Add Equipment
<.icon name="hero-plus" class="h-5 w-5" /> Add Device
</.button>
</div>
</div>
<% else %>
<div class="space-y-3">
<.link
:for={eq <- @equipment}
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{eq.id}"}
:for={eq <- @device}
navigate={~p"/orgs/#{@current_organization.slug}/devices/#{eq.id}"}
class="flex items-center justify-between rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900 hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
>
<div>

View file

@ -56,9 +56,9 @@ defmodule ToweropsWeb.Router do
# Data endpoints
get "/organizations", MobileController, :list_organizations
get "/organizations/:organization_id/sites", MobileController, :list_sites
get "/organizations/:organization_id/equipment", MobileController, :list_equipment
get "/organizations/:organization_id/devices", MobileController, :list_devices
get "/organizations/:organization_id/alerts", MobileController, :list_alerts
get "/equipment/:id", MobileController, :get_equipment
get "/devices/:id", MobileController, :get_equipment
end
# WebAuthn API routes
@ -182,12 +182,12 @@ defmodule ToweropsWeb.Router do
live "/sites/:id", SiteLive.Show, :show
live "/sites/:id/edit", SiteLive.Form, :edit
# Equipment routes
live "/equipment", EquipmentLive.Index, :index
live "/equipment/new", EquipmentLive.Form, :new
live "/equipment/:id", EquipmentLive.Show, :show
live "/equipment/:id/edit", EquipmentLive.Form, :edit
live "/equipment/:id/graph/:sensor_type", GraphLive.Show, :show
# Device routes
live "/devices", DeviceLive.Index, :index
live "/devices/new", DeviceLive.Form, :new
live "/devices/:id", DeviceLive.Show, :show
live "/devices/:id/edit", DeviceLive.Form, :edit
live "/devices/:id/graph/:sensor_type", GraphLive.Show, :show
# Alert routes
live "/alerts", AlertLive.Index, :index

View file

@ -234,8 +234,8 @@ defmodule ToweropsWeb.UserAuth do
# Get user's organizations (ordered by most recently joined first)
case Towerops.Organizations.list_user_organizations(user.id) do
[first_org | _] ->
# Redirect to the first organization's equipment page
~p"/orgs/#{first_org.slug}/equipment"
# device page
~p"/orgs/#{first_org.slug}/devices"
[] ->
# No organizations yet, go to org list
@ -545,10 +545,10 @@ defmodule ToweropsWeb.UserAuth do
ip_address: ip
})
# Restore superuser session and redirect to their default org's equipment page
# device page
redirect_path =
case Towerops.Organizations.list_user_organizations(superuser.id) do
[first_org | _] -> ~p"/orgs/#{first_org.slug}/equipment"
[first_org | _] -> ~p"/orgs/#{first_org.slug}/devices"
[] -> ~p"/orgs"
end

View file

@ -6,10 +6,10 @@ package towerops.agent;
message AgentConfig {
string version = 1;
uint32 poll_interval_seconds = 2;
repeated Equipment equipment = 3;
repeated Device devices = 3;
}
message Equipment {
message Device {
string id = 1;
string name = 2;
string ip_address = 3;
@ -112,8 +112,8 @@ enum QueryType {
message AgentJob {
string job_id = 1; // Unique job identifier (e.g., "discover:eq123")
JobType job_type = 2; // DISCOVER or POLL
string equipment_id = 3; // Equipment UUID
SnmpDevice device = 4; // SNMP connection details
string device_id = 3; // Device UUID
SnmpDevice snmp_device = 4; // SNMP connection details
repeated SnmpQuery queries = 5; // Queries to execute
}
@ -134,7 +134,7 @@ message SnmpQuery {
}
message SnmpResult {
string equipment_id = 1;
string device_id = 1;
JobType job_type = 2;
map<string, string> oid_values = 3; // OID value mapping
int64 timestamp = 4; // Unix timestamp in seconds
@ -148,7 +148,7 @@ message AgentHeartbeat {
}
message AgentError {
string equipment_id = 1;
string device_id = 1;
string job_id = 2;
string message = 3;
int64 timestamp = 4;

View file

@ -2,7 +2,7 @@ defmodule Towerops.Repo.Migrations.AddMonitoringEnabledIndex do
use Ecto.Migration
def change do
# Add index for monitoring_enabled to optimize list_monitored_equipment/0 query
# Add index for monitoring_enabled to optimize list_monitored_devices/0 query
create index(:equipment, [:monitoring_enabled])
end
end

View file

@ -15,7 +15,7 @@ defmodule Towerops.Repo.Migrations.AddCompositeIndexesForAgentQueries do
create index(:agent_tokens, [:organization_id, :last_seen_at])
# Composite index for equipment assignment queries (equipment_id + enabled)
# This improves queries that check if equipment has an active assignment
# if device has an active assignment
create index(:agent_assignments, [:equipment_id, :enabled])
end
end

View file

@ -0,0 +1,108 @@
defmodule Towerops.Repo.Migrations.RenameEquipmentToDevices do
use Ecto.Migration
def up do
# Rename the main table
rename table(:equipment), to: table(:devices)
# Rename foreign key columns in related tables
rename table(:alerts), :equipment_id, to: :device_id
rename table(:snmp_devices), :equipment_id, to: :device_id
rename table(:monitoring_checks), :equipment_id, to: :device_id
rename table(:equipment_events), :equipment_id, to: :device_id
rename table(:agent_assignments), :equipment_id, to: :device_id
rename table(:snmp_neighbors), :equipment_id, to: :device_id
# Rename equipment_events table
rename table(:equipment_events), to: table(:device_events)
# Rename indexes (they get renamed automatically with the table, but foreign key indexes need explicit handling)
execute "ALTER INDEX IF EXISTS equipment_pkey RENAME TO devices_pkey"
execute "ALTER INDEX IF EXISTS equipment_site_id_index RENAME TO devices_site_id_index"
execute "ALTER INDEX IF EXISTS equipment_monitoring_enabled_index RENAME TO devices_monitoring_enabled_index"
execute "ALTER INDEX IF EXISTS equipment_snmp_enabled_monitoring_enabled_site_id_index RENAME TO devices_snmp_enabled_monitoring_enabled_site_id_index"
# Rename foreign key constraints
execute "ALTER TABLE alerts DROP CONSTRAINT IF EXISTS alerts_equipment_id_fkey"
execute "ALTER TABLE alerts ADD CONSTRAINT alerts_device_id_fkey FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE"
execute "ALTER TABLE snmp_devices DROP CONSTRAINT IF EXISTS snmp_devices_equipment_id_fkey"
execute "ALTER TABLE snmp_devices ADD CONSTRAINT snmp_devices_device_id_fkey FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE"
execute "ALTER TABLE monitoring_checks DROP CONSTRAINT IF EXISTS monitoring_checks_equipment_id_fkey"
execute "ALTER TABLE monitoring_checks ADD CONSTRAINT monitoring_checks_device_id_fkey FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE"
execute "ALTER TABLE device_events DROP CONSTRAINT IF EXISTS equipment_events_equipment_id_fkey"
execute "ALTER TABLE device_events ADD CONSTRAINT device_events_device_id_fkey FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE"
execute "ALTER TABLE agent_assignments DROP CONSTRAINT IF EXISTS agent_assignments_equipment_id_fkey"
execute "ALTER TABLE agent_assignments ADD CONSTRAINT agent_assignments_device_id_fkey FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE"
execute "ALTER TABLE snmp_neighbors DROP CONSTRAINT IF EXISTS snmp_neighbors_equipment_id_fkey"
execute "ALTER TABLE snmp_neighbors ADD CONSTRAINT snmp_neighbors_device_id_fkey FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE"
# Rename composite indexes
execute "ALTER INDEX IF EXISTS agent_assignments_agent_token_id_equipment_id_index RENAME TO agent_assignments_agent_token_id_device_id_index"
execute "ALTER INDEX IF EXISTS agent_assignments_equipment_id_index RENAME TO agent_assignments_device_id_index"
execute "ALTER INDEX IF EXISTS agent_assignments_equipment_id_enabled_index RENAME TO agent_assignments_device_id_enabled_index"
execute "ALTER INDEX IF EXISTS alerts_equipment_id_severity_status_index RENAME TO alerts_device_id_severity_status_index"
end
def down do
# Reverse all changes
rename table(:devices), to: table(:equipment)
rename table(:alerts), :device_id, to: :equipment_id
rename table(:snmp_devices), :device_id, to: :equipment_id
rename table(:monitoring_checks), :device_id, to: :equipment_id
rename table(:device_events), :device_id, to: :equipment_id
rename table(:agent_assignments), :device_id, to: :equipment_id
rename table(:snmp_neighbors), :device_id, to: :equipment_id
rename table(:device_events), to: table(:equipment_events)
execute "ALTER INDEX IF EXISTS devices_pkey RENAME TO equipment_pkey"
execute "ALTER INDEX IF EXISTS devices_site_id_index RENAME TO equipment_site_id_index"
execute "ALTER INDEX IF EXISTS devices_monitoring_enabled_index RENAME TO equipment_monitoring_enabled_index"
execute "ALTER INDEX IF EXISTS devices_snmp_enabled_monitoring_enabled_site_id_index RENAME TO equipment_snmp_enabled_monitoring_enabled_site_id_index"
execute "ALTER TABLE alerts DROP CONSTRAINT IF EXISTS alerts_device_id_fkey"
execute "ALTER TABLE alerts ADD CONSTRAINT alerts_equipment_id_fkey FOREIGN KEY (equipment_id) REFERENCES equipment(id) ON DELETE CASCADE"
execute "ALTER TABLE snmp_devices DROP CONSTRAINT IF EXISTS snmp_devices_device_id_fkey"
execute "ALTER TABLE snmp_devices ADD CONSTRAINT snmp_devices_equipment_id_fkey FOREIGN KEY (equipment_id) REFERENCES equipment(id) ON DELETE CASCADE"
execute "ALTER TABLE monitoring_checks DROP CONSTRAINT IF EXISTS monitoring_checks_device_id_fkey"
execute "ALTER TABLE monitoring_checks ADD CONSTRAINT monitoring_checks_equipment_id_fkey FOREIGN KEY (equipment_id) REFERENCES equipment(id) ON DELETE CASCADE"
execute "ALTER TABLE equipment_events DROP CONSTRAINT IF EXISTS device_events_device_id_fkey"
execute "ALTER TABLE equipment_events ADD CONSTRAINT equipment_events_equipment_id_fkey FOREIGN KEY (equipment_id) REFERENCES equipment(id) ON DELETE CASCADE"
execute "ALTER TABLE agent_assignments DROP CONSTRAINT IF EXISTS agent_assignments_device_id_fkey"
execute "ALTER TABLE agent_assignments ADD CONSTRAINT agent_assignments_equipment_id_fkey FOREIGN KEY (equipment_id) REFERENCES equipment(id) ON DELETE CASCADE"
execute "ALTER TABLE snmp_neighbors DROP CONSTRAINT IF EXISTS snmp_neighbors_device_id_fkey"
execute "ALTER TABLE snmp_neighbors ADD CONSTRAINT snmp_neighbors_equipment_id_fkey FOREIGN KEY (equipment_id) REFERENCES equipment(id) ON DELETE CASCADE"
execute "ALTER INDEX IF EXISTS agent_assignments_agent_token_id_device_id_index RENAME TO agent_assignments_agent_token_id_equipment_id_index"
execute "ALTER INDEX IF EXISTS alerts_device_id_severity_status_index RENAME TO alerts_equipment_id_severity_status_index"
end
end

View file

@ -7,11 +7,11 @@ defmodule Towerops.Integration.SnmpIntegrationTest do
Requirements:
- Network access to test devices
- SNMP-enabled equipment (configure IP/community below)
- SNMP-enabled devices (configure IP/community below)
"""
use ExUnit.Case, async: false
alias Towerops.Monitoring.EquipmentMonitor
alias Towerops.Monitoring.DeviceMonitor
alias Towerops.Snmp.Client
alias Towerops.Snmp.Poller
@ -96,7 +96,7 @@ defmodule Towerops.Integration.SnmpIntegrationTest do
end
end
describe "Equipment Monitor with SNMP" do
describe "Device Monitor with SNMP" do
test "creates SNMP-specific alert messages when device goes down" do
# This test makes real SNMP calls to an unreachable IP to verify timeout handling
import Towerops.AccountsFixtures
@ -110,8 +110,8 @@ defmodule Towerops.Integration.SnmpIntegrationTest do
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "SNMP Router",
ip_address: "192.0.2.1",
site_id: site.id,
@ -123,21 +123,21 @@ defmodule Towerops.Integration.SnmpIntegrationTest do
})
# Set to up first so status change will trigger
{:ok, equipment} = Towerops.Equipment.update_equipment_status(equipment, :up)
{:ok, device} = Towerops.Devices.update_device_status(device, :up)
{:ok, state} = EquipmentMonitor.init(equipment.id)
{:ok, state} = DeviceMonitor.init(device.id)
# Trigger check - will timeout trying to reach 192.0.2.1
{:noreply, _state} =
EquipmentMonitor.handle_info(:check_equipment, state)
DeviceMonitor.handle_info(:check_device, state)
Process.sleep(200)
alerts = Towerops.Alerts.list_equipment_alerts(equipment.id)
alerts = Towerops.Alerts.list_devices_alerts(device.id)
refute Enum.empty?(alerts)
alert = hd(alerts)
assert alert.alert_type == :equipment_down
assert alert.alert_type == :device_down
assert String.contains?(alert.message, "SNMP")
end
end

View file

@ -13,7 +13,7 @@ defmodule Towerops.AgentsFixtures do
Agents.create_agent_token(organization_id, name)
end
def agent_assignment_fixture(agent_token_id, equipment_id) do
Agents.assign_equipment_to_agent(agent_token_id, equipment_id)
def agent_assignment_fixture(agent_token_id, device_id) do
Agents.assign_device_to_agent(agent_token_id, device_id)
end
end

View file

@ -48,8 +48,8 @@ defmodule Towerops.AgentsFixturesTest do
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id
@ -57,10 +57,10 @@ defmodule Towerops.AgentsFixturesTest do
{:ok, agent_token, _token_string} = agent_token_fixture(organization.id)
{:ok, assignment} = agent_assignment_fixture(agent_token.id, equipment.id)
{:ok, assignment} = agent_assignment_fixture(agent_token.id, device.id)
assert assignment.agent_token_id == agent_token.id
assert assignment.equipment_id == equipment.id
assert assignment.device_id == device.id
assert assignment.enabled == true
end
end

View file

@ -6,7 +6,7 @@ defmodule Towerops.Agents.StatsTest do
alias Towerops.Agents
alias Towerops.Agents.Stats
alias Towerops.Equipment
alias Towerops.Devices.Device
alias Towerops.Repo
alias Towerops.Sites
alias Towerops.Snmp.Device
@ -23,7 +23,7 @@ defmodule Towerops.Agents.StatsTest do
assert Stats.get_organization_agent_health(org.id) == []
end
test "returns online agents with equipment counts" do
test "returns online agents with device counts" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
@ -43,7 +43,7 @@ defmodule Towerops.Agents.StatsTest do
assert agent_health.id == agent_token.id
assert agent_health.name == "Test Agent"
assert agent_health.online == true
assert agent_health.equipment_count == 0
assert agent_health.device_count == 0
assert agent_health.version == "0.1.0"
assert agent_health.hostname == "test-host"
end
@ -67,15 +67,15 @@ defmodule Towerops.Agents.StatsTest do
assert agent_health.online == false
end
test "includes equipment count from hierarchical assignments" do
test "includes device count from hierarchical assignments" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
site_id: site.id,
@ -84,7 +84,7 @@ defmodule Towerops.Agents.StatsTest do
snmp_version: "2c"
})
{:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
{:ok, _assignment} = Agents.assign_device_to_agent(agent_token.id, device.id)
agent_token
|> Ecto.Changeset.change(%{last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)})
@ -94,12 +94,12 @@ defmodule Towerops.Agents.StatsTest do
assert length(health) == 1
agent_health = hd(health)
assert agent_health.equipment_count == 1
assert agent_health.device_count == 1
end
end
describe "get_equipment_assignment_breakdown/1" do
test "returns zero counts for organization with no equipment" do
test "returns zero counts for organization with no devices" do
user = user_fixture()
org = organization_fixture(user.id)
@ -108,15 +108,15 @@ defmodule Towerops.Agents.StatsTest do
assert breakdown == %{direct: 0, site: 0, organization: 0, cloud: 0}
end
test "counts equipment by assignment type" do
test "counts device by assignment type" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
# Direct assignment
{:ok, equipment1} =
Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
@ -125,11 +125,11 @@ defmodule Towerops.Agents.StatsTest do
snmp_version: "2c"
})
{:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment1.id)
{:ok, _assignment} = Agents.assign_device_to_agent(agent_token.id, device1.id)
# Cloud polling (no agent)
{:ok, _equipment2} =
Equipment.create_equipment(%{
{:ok, _device2} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
@ -204,16 +204,16 @@ defmodule Towerops.Agents.StatsTest do
assert high_load == []
end
test "returns agents with equipment count above threshold" do
test "returns agents with device count above threshold" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "High Load Agent")
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
# Create 3 equipment and assign all to agent
# Create 3 devices and assign all to agent
for i <- 1..3 do
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router #{i}",
ip_address: "192.168.1.#{i}",
site_id: site.id,
@ -222,7 +222,7 @@ defmodule Towerops.Agents.StatsTest do
snmp_version: "2c"
})
{:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
{:ok, _assignment} = Agents.assign_device_to_agent(agent_token.id, device.id)
end
high_load = Stats.get_high_load_agents(org.id, 2)
@ -231,7 +231,7 @@ defmodule Towerops.Agents.StatsTest do
agent = hd(high_load)
assert agent.id == agent_token.id
assert agent.name == "High Load Agent"
assert agent.equipment_count == 3
assert agent.device_count == 3
end
test "respects custom threshold parameter" do
@ -242,8 +242,8 @@ defmodule Towerops.Agents.StatsTest do
# Create 5 equipment
for i <- 1..5 do
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router #{i}",
ip_address: "192.168.1.#{i}",
site_id: site.id,
@ -252,7 +252,7 @@ defmodule Towerops.Agents.StatsTest do
snmp_version: "2c"
})
{:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
{:ok, _assignment} = Agents.assign_device_to_agent(agent_token.id, device.id)
end
# Should appear with threshold 4
@ -266,14 +266,14 @@ defmodule Towerops.Agents.StatsTest do
end
describe "get_unmonitored_equipment/1" do
test "returns empty list when all equipment has agents" do
test "returns empty list when all devices has agents" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Agent")
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
@ -283,20 +283,20 @@ defmodule Towerops.Agents.StatsTest do
monitoring_enabled: true
})
{:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
{:ok, _assignment} = Agents.assign_device_to_agent(agent_token.id, device.id)
unmonitored = Stats.get_unmonitored_equipment(org.id)
assert unmonitored == []
end
test "returns equipment with no agent assigned" do
test "returns device with no agent assigned" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Unmonitored Router",
ip_address: "192.168.1.1",
site_id: site.id,
@ -310,19 +310,19 @@ defmodule Towerops.Agents.StatsTest do
assert length(unmonitored) == 1
eq = hd(unmonitored)
assert eq.id == equipment.id
assert eq.id == device.id
assert eq.name == "Unmonitored Router"
assert eq.ip_address == "192.168.1.1"
assert eq.site_name == "Test Site"
end
test "excludes equipment with SNMP disabled" do
test "excludes device with SNMP disabled" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
{:ok, _equipment} =
Equipment.create_equipment(%{
{:ok, _device} =
Towerops.Devices.create_device(%{
name: "SNMP Disabled",
ip_address: "192.168.1.1",
site_id: site.id,
@ -335,13 +335,13 @@ defmodule Towerops.Agents.StatsTest do
assert unmonitored == []
end
test "excludes equipment with monitoring disabled" do
test "excludes device with monitoring disabled" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
{:ok, _equipment} =
Equipment.create_equipment(%{
{:ok, _device} =
Towerops.Devices.create_device(%{
name: "Monitoring Disabled",
ip_address: "192.168.1.1",
site_id: site.id,
@ -378,8 +378,8 @@ defmodule Towerops.Agents.StatsTest do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Agent")
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
@ -388,13 +388,13 @@ defmodule Towerops.Agents.StatsTest do
snmp_version: "2c"
})
{:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
{:ok, _assignment} = Agents.assign_device_to_agent(agent_token.id, device.id)
# Create SNMP device, sensor, and interface
device =
%Device{}
|> Device.changeset(%{
equipment_id: equipment.id,
device_id: device.id,
sys_name: "Test Device",
sys_descr: "Test"
})

View file

@ -98,7 +98,7 @@ defmodule Towerops.AgentsTest do
end
end
describe "assign_equipment_to_agent/2" do
describe "assign_device_to_agent/2" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
@ -106,42 +106,42 @@ defmodule Towerops.AgentsTest do
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id
})
%{site: site, equipment: equipment}
%{site: site, device: device}
end
test "assigns equipment to agent", %{organization: org, equipment: equipment} do
test "assigns equipment to agent", %{organization: org, device: device} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
assert {:ok, assignment} =
Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
Agents.assign_device_to_agent(agent_token.id, device.id)
assert assignment.agent_token_id == agent_token.id
assert assignment.equipment_id == equipment.id
assert assignment.device_id == device.id
assert assignment.enabled == true
end
test "prevents assigning equipment to multiple agents", %{
organization: org,
equipment: equipment
device: device
} do
{:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
{:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment.id)
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device.id)
assert {:error, changeset} = Agents.assign_equipment_to_agent(agent2.id, equipment.id)
assert "has already been taken" in errors_on(changeset).equipment_id
assert {:error, changeset} = Agents.assign_device_to_agent(agent2.id, device.id)
assert "has already been taken" in errors_on(changeset).device_id
end
end
describe "unassign_equipment/1" do
describe "unassign_device/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
@ -149,8 +149,8 @@ defmodule Towerops.AgentsTest do
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id
@ -158,14 +158,14 @@ defmodule Towerops.AgentsTest do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
%{site: site, equipment: equipment, agent_token: agent_token}
%{site: site, device: device, agent_token: agent_token}
end
test "removes equipment assignment", %{agent_token: agent_token, equipment: equipment} do
{:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
{1, _} = Agents.unassign_equipment(equipment.id)
test "removes equipment assignment", %{agent_token: agent_token, device: device} do
{:ok, _assignment} = Agents.assign_device_to_agent(agent_token.id, device.id)
{1, _} = Agents.unassign_device(device.id)
assert Repo.get_by(AgentAssignment, equipment_id: equipment.id) == nil
assert Repo.get_by(AgentAssignment, device_id: device.id) == nil
end
end
@ -207,7 +207,7 @@ defmodule Towerops.AgentsTest do
end
end
describe "get_equipment_assignment/1" do
describe "get_device_assignment/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
@ -215,8 +215,8 @@ defmodule Towerops.AgentsTest do
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id
@ -224,25 +224,25 @@ defmodule Towerops.AgentsTest do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
%{site: site, equipment: equipment, agent_token: agent_token}
%{site: site, device: device, agent_token: agent_token}
end
test "returns assignment when equipment is assigned", %{
agent_token: agent_token,
equipment: equipment
device: device
} do
{:ok, assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
{:ok, assignment} = Agents.assign_device_to_agent(agent_token.id, device.id)
retrieved_assignment = Agents.get_equipment_assignment(equipment.id)
retrieved_assignment = Agents.get_device_assignment(device.id)
assert retrieved_assignment.id == assignment.id
end
test "returns nil when equipment is not assigned", %{equipment: equipment} do
assert Agents.get_equipment_assignment(equipment.id) == nil
test "returns nil when equipment is not assigned", %{device: device} do
assert Agents.get_device_assignment(device.id) == nil
end
end
describe "update_equipment_assignment/2" do
describe "update_device_assignment/2" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
@ -250,8 +250,8 @@ defmodule Towerops.AgentsTest do
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id
@ -262,49 +262,49 @@ defmodule Towerops.AgentsTest do
%{
site: site,
equipment: equipment,
device: device,
agent_token1: agent_token1,
agent_token2: agent_token2
}
end
test "creates new assignment when equipment is unassigned", %{
equipment: equipment,
device: device,
agent_token1: agent_token1
} do
assert {:ok, assignment} =
Agents.update_equipment_assignment(equipment.id, agent_token1.id)
Agents.update_device_assignment(device.id, agent_token1.id)
assert assignment.agent_token_id == agent_token1.id
assert assignment.equipment_id == equipment.id
assert assignment.device_id == device.id
end
test "updates existing assignment to new agent", %{
equipment: equipment,
device: device,
agent_token1: agent_token1,
agent_token2: agent_token2
} do
{:ok, _} = Agents.assign_equipment_to_agent(agent_token1.id, equipment.id)
{:ok, _} = Agents.assign_device_to_agent(agent_token1.id, device.id)
assert {:ok, updated_assignment} =
Agents.update_equipment_assignment(equipment.id, agent_token2.id)
Agents.update_device_assignment(device.id, agent_token2.id)
assert updated_assignment.agent_token_id == agent_token2.id
assert updated_assignment.equipment_id == equipment.id
assert updated_assignment.device_id == device.id
end
test "removes assignment when set to nil", %{
equipment: equipment,
device: device,
agent_token1: agent_token1
} do
{:ok, _} = Agents.assign_equipment_to_agent(agent_token1.id, equipment.id)
{:ok, _} = Agents.assign_device_to_agent(agent_token1.id, device.id)
assert {:ok, nil} = Agents.update_equipment_assignment(equipment.id, nil)
assert Agents.get_equipment_assignment(equipment.id) == nil
assert {:ok, nil} = Agents.update_device_assignment(device.id, nil)
assert Agents.get_device_assignment(device.id) == nil
end
end
describe "list_agent_equipment/1" do
describe "list_agent_devices/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
@ -312,50 +312,50 @@ defmodule Towerops.AgentsTest do
organization_id: org.id
})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Test Equipment 1",
ip_address: "192.168.1.1",
site_id: site.id
})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Test Equipment 2",
ip_address: "192.168.1.2",
site_id: site.id
})
{:ok, equipment3} =
Towerops.Equipment.create_equipment(%{
{:ok, device3} =
Towerops.Devices.create_device(%{
name: "Test Equipment 3",
ip_address: "192.168.1.3",
site_id: site.id
})
%{site: site, equipment1: equipment1, equipment2: equipment2, equipment3: equipment3}
%{site: site, device1: device1, device2: device2, device3: device3}
end
test "returns equipment assigned to agent with preloads", %{
test "returns devices assigned to agent with preloads", %{
organization: org,
equipment1: equipment1,
equipment2: equipment2,
equipment3: equipment3
device1: device1,
device2: device2,
device3: device3
} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
{:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment1.id)
{:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment2.id)
{:ok, _} = Agents.assign_device_to_agent(agent_token.id, device1.id)
{:ok, _} = Agents.assign_device_to_agent(agent_token.id, device2.id)
equipment_list = Agents.list_agent_equipment(agent_token.id)
device_list = Agents.list_agent_devices(agent_token.id)
assert length(equipment_list) == 2
assert Enum.any?(equipment_list, &(&1.id == equipment1.id))
assert Enum.any?(equipment_list, &(&1.id == equipment2.id))
refute Enum.any?(equipment_list, &(&1.id == equipment3.id))
assert length(device_list) == 2
assert Enum.any?(device_list, &(&1.id == device1.id))
assert Enum.any?(device_list, &(&1.id == device2.id))
refute Enum.any?(device_list, &(&1.id == device3.id))
# Check preloads
equipment = hd(equipment_list)
assert Ecto.assoc_loaded?(equipment.site)
device = hd(device_list)
assert Ecto.assoc_loaded?(device.site)
end
end
@ -371,8 +371,8 @@ defmodule Towerops.AgentsTest do
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id
@ -383,28 +383,28 @@ defmodule Towerops.AgentsTest do
agent2: agent2,
agent3: agent3,
site: site,
equipment: equipment
device: device
}
end
test "returns equipment-level assignment (highest priority)", %{
test "returns devices-level assignment (highest priority)", %{
organization: org,
agent1: agent1,
agent2: agent2,
agent3: agent3,
site: site,
equipment: equipment
device: device
} do
# Set up hierarchy: org default, site override, equipment override
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, _site} = Towerops.Sites.update_site(site, %{agent_token_id: agent2.id})
{:ok, _} = Agents.assign_equipment_to_agent(agent3.id, equipment.id)
{:ok, _} = Agents.assign_device_to_agent(agent3.id, device.id)
# Preload necessary associations
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
device = Repo.preload(device, site: [organization: :default_agent_token])
# Equipment assignment should take precedence
assert Agents.get_effective_agent_token(equipment) == agent3.id
assert Agents.get_effective_agent_token(device) == agent3.id
end
test "returns site-level assignment when equipment not assigned", %{
@ -412,39 +412,39 @@ defmodule Towerops.AgentsTest do
agent1: agent1,
agent2: agent2,
site: site,
equipment: equipment
device: device
} do
# Set up hierarchy: org default and site override, but no equipment assignment
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, _site} = Towerops.Sites.update_site(site, %{agent_token_id: agent2.id})
# Preload necessary associations
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
device = Repo.preload(device, site: [organization: :default_agent_token])
# Site assignment should be used
assert Agents.get_effective_agent_token(equipment) == agent2.id
assert Agents.get_effective_agent_token(device) == agent2.id
end
test "returns organization-level assignment when site and equipment not assigned", %{
organization: org,
agent1: agent1,
equipment: equipment
device: device
} do
# Set up hierarchy: only org default
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
# Preload necessary associations
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
device = Repo.preload(device, site: [organization: :default_agent_token])
# Organization default should be used
assert Agents.get_effective_agent_token(equipment) == agent1.id
assert Agents.get_effective_agent_token(device) == agent1.id
end
test "returns nil when no agent assigned at any level", %{equipment: equipment} do
test "returns nil when no agent assigned at any level", %{device: device} do
# No assignments at any level
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
device = Repo.preload(device, site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(equipment) == nil
assert Agents.get_effective_agent_token(device) == nil
end
end
@ -460,8 +460,8 @@ defmodule Towerops.AgentsTest do
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id
@ -472,56 +472,56 @@ defmodule Towerops.AgentsTest do
agent2: agent2,
agent3: agent3,
site: site,
equipment: equipment
device: device
}
end
test "returns equipment source for equipment-level assignment", %{
test "returns devices source for device-level assignment", %{
agent3: agent3,
equipment: equipment
device: device
} do
{:ok, _} = Agents.assign_equipment_to_agent(agent3.id, equipment.id)
{:ok, _} = Agents.assign_device_to_agent(agent3.id, device.id)
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
device = Repo.preload(device, site: [organization: :default_agent_token])
assert {agent_id, :equipment} = Agents.get_effective_agent_token_with_source(equipment)
assert {agent_id, :device} = Agents.get_effective_agent_token_with_source(device)
assert agent_id == agent3.id
end
test "returns site source for site-level assignment", %{
agent2: agent2,
site: site,
equipment: equipment
device: device
} do
{:ok, _site} = Towerops.Sites.update_site(site, %{agent_token_id: agent2.id})
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
device = Repo.preload(device, site: [organization: :default_agent_token])
assert {agent_id, :site} = Agents.get_effective_agent_token_with_source(equipment)
assert {agent_id, :site} = Agents.get_effective_agent_token_with_source(device)
assert agent_id == agent2.id
end
test "returns organization source for organization-level assignment", %{
organization: org,
agent1: agent1,
equipment: equipment
device: device
} do
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
device = Repo.preload(device, site: [organization: :default_agent_token])
assert {agent_id, :organization} = Agents.get_effective_agent_token_with_source(equipment)
assert {agent_id, :organization} = Agents.get_effective_agent_token_with_source(device)
assert agent_id == agent1.id
end
test "returns none source when no assignment", %{equipment: equipment} do
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
test "returns none source when no assignment", %{device: device} do
device = Repo.preload(device, site: [organization: :default_agent_token])
assert {nil, :none} = Agents.get_effective_agent_token_with_source(equipment)
assert {nil, :none} = Agents.get_effective_agent_token_with_source(device)
end
end
describe "count_assigned_equipment/1" do
describe "count_assigned_devices/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
@ -529,63 +529,63 @@ defmodule Towerops.AgentsTest do
organization_id: org.id
})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Test Equipment 1",
ip_address: "192.168.1.1",
site_id: site.id
})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Test Equipment 2",
ip_address: "192.168.1.2",
site_id: site.id
})
{:ok, equipment3} =
Towerops.Equipment.create_equipment(%{
{:ok, device3} =
Towerops.Devices.create_device(%{
name: "Test Equipment 3",
ip_address: "192.168.1.3",
site_id: site.id
})
%{site: site, equipment1: equipment1, equipment2: equipment2, equipment3: equipment3}
%{site: site, device1: device1, device2: device2, device3: device3}
end
test "counts directly assigned equipment", %{
organization: org,
equipment1: equipment1,
equipment2: equipment2
device1: device1,
device2: device2
} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
# Initially no assignments
assert Agents.count_assigned_equipment(agent_token.id) == 0
assert Agents.count_assigned_devices(agent_token.id) == 0
# Assign two pieces of equipment
{:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment1.id)
assert Agents.count_assigned_equipment(agent_token.id) == 1
{:ok, _} = Agents.assign_device_to_agent(agent_token.id, device1.id)
assert Agents.count_assigned_devices(agent_token.id) == 1
{:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment2.id)
assert Agents.count_assigned_equipment(agent_token.id) == 2
{:ok, _} = Agents.assign_device_to_agent(agent_token.id, device2.id)
assert Agents.count_assigned_devices(agent_token.id) == 2
end
test "does not count disabled assignments", %{
organization: org,
equipment1: equipment1
device1: device1
} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
{:ok, assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment1.id)
{:ok, assignment} = Agents.assign_device_to_agent(agent_token.id, device1.id)
assert Agents.count_assigned_equipment(agent_token.id) == 1
assert Agents.count_assigned_devices(agent_token.id) == 1
# Disable the assignment
assignment
|> Ecto.Changeset.change(enabled: false)
|> Repo.update!()
assert Agents.count_assigned_equipment(agent_token.id) == 0
assert Agents.count_assigned_devices(agent_token.id) == 0
end
end
@ -616,13 +616,13 @@ defmodule Towerops.AgentsTest do
}
end
test "returns equipment with direct agent assignment", %{
test "returns device with direct agent assignment", %{
agent1: agent1,
agent2: agent2,
site1: site1
} do
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
@ -631,8 +631,8 @@ defmodule Towerops.AgentsTest do
snmp_community: "public"
})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
@ -642,21 +642,21 @@ defmodule Towerops.AgentsTest do
})
# Assign equipment to agent1
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment1.id)
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment2.id)
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device1.id)
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device2.id)
# Agent1 should see both equipment
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == equipment1.id))
assert Enum.any?(targets, &(&1.id == equipment2.id))
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
# Agent2 should see nothing
targets = Agents.list_agent_polling_targets(agent2.id)
assert targets == []
end
test "returns equipment inheriting from site agent", %{
test "returns device inheriting from site agent", %{
agent1: agent1,
agent2: agent2,
site1: site1
@ -664,8 +664,8 @@ defmodule Towerops.AgentsTest do
# Set site1 to use agent1
{:ok, _site} = Towerops.Sites.update_site(site1, %{agent_token_id: agent1.id})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
@ -674,8 +674,8 @@ defmodule Towerops.AgentsTest do
snmp_community: "public"
})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
@ -687,15 +687,15 @@ defmodule Towerops.AgentsTest do
# Agent1 should see both equipment (inherited from site)
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == equipment1.id))
assert Enum.any?(targets, &(&1.id == equipment2.id))
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
# Agent2 should see nothing
targets = Agents.list_agent_polling_targets(agent2.id)
assert targets == []
end
test "returns equipment inheriting from organization default", %{
test "returns device inheriting from organization default", %{
organization: org,
agent1: agent1,
agent2: agent2,
@ -705,8 +705,8 @@ defmodule Towerops.AgentsTest do
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
@ -715,8 +715,8 @@ defmodule Towerops.AgentsTest do
snmp_community: "public"
})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
@ -728,8 +728,8 @@ defmodule Towerops.AgentsTest do
# Agent1 should see both equipment (inherited from organization)
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == equipment1.id))
assert Enum.any?(targets, &(&1.id == equipment2.id))
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
# Agent2 should see nothing
targets = Agents.list_agent_polling_targets(agent2.id)
@ -749,8 +749,8 @@ defmodule Towerops.AgentsTest do
{:ok, _site} = Towerops.Sites.update_site(site1, %{agent_token_id: agent2.id})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
@ -760,12 +760,12 @@ defmodule Towerops.AgentsTest do
})
# Directly assign to agent3
{:ok, _} = Agents.assign_equipment_to_agent(agent3.id, equipment1.id)
{:ok, _} = Agents.assign_device_to_agent(agent3.id, device1.id)
# Only agent3 should see the equipment
targets = Agents.list_agent_polling_targets(agent3.id)
assert length(targets) == 1
assert hd(targets).id == equipment1.id
assert hd(targets).id == device1.id
# Agent1 and agent2 should not see it
assert Agents.list_agent_polling_targets(agent1.id) == []
@ -784,8 +784,8 @@ defmodule Towerops.AgentsTest do
{:ok, _site} = Towerops.Sites.update_site(site1, %{agent_token_id: agent2.id})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
@ -797,19 +797,19 @@ defmodule Towerops.AgentsTest do
# Agent2 should see the equipment (site level)
targets = Agents.list_agent_polling_targets(agent2.id)
assert length(targets) == 1
assert hd(targets).id == equipment1.id
assert hd(targets).id == device1.id
# Agent1 should not see it (org default is overridden by site)
assert Agents.list_agent_polling_targets(agent1.id) == []
end
test "only returns SNMP-enabled equipment", %{
test "only returns SNMP-enabled devices", %{
agent1: agent1,
site1: site1
} do
# Equipment with SNMP enabled
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
@ -819,8 +819,8 @@ defmodule Towerops.AgentsTest do
})
# Equipment with SNMP disabled
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
@ -828,17 +828,17 @@ defmodule Towerops.AgentsTest do
})
# Assign both to agent1
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment1.id)
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment2.id)
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device1.id)
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device2.id)
# Agent1 should only see SNMP-enabled equipment
# Agent1 should only see SNMP-enabled devices
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 1
assert hd(targets).id == equipment1.id
assert hd(targets).id == device1.id
assert hd(targets).snmp_enabled == true
end
test "returns equipment from multiple sites with different assignment levels", %{
test "returns device from multiple sites with different assignment levels", %{
organization: org,
agent1: agent1,
agent2: agent2,
@ -853,8 +853,8 @@ defmodule Towerops.AgentsTest do
{:ok, _site} = Towerops.Sites.update_site(site2, %{agent_token_id: agent2.id})
# Equipment at site1 (inherits org default - agent1)
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
@ -864,8 +864,8 @@ defmodule Towerops.AgentsTest do
})
# Equipment at site2 (inherits site default - agent2)
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site2.id,
@ -874,23 +874,23 @@ defmodule Towerops.AgentsTest do
snmp_community: "public"
})
# Agent1 should see equipment1 (from site1)
# Agent1 should see device1 (from site1)
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 1
assert hd(targets).id == equipment1.id
assert hd(targets).id == device1.id
# Agent2 should see equipment2 (from site2)
# Agent2 should see device2 (from site2)
targets = Agents.list_agent_polling_targets(agent2.id)
assert length(targets) == 1
assert hd(targets).id == equipment2.id
assert hd(targets).id == device2.id
end
test "preloads necessary associations for API response", %{
agent1: agent1,
site1: site1
} do
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
@ -899,14 +899,14 @@ defmodule Towerops.AgentsTest do
snmp_community: "public"
})
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment1.id)
{:ok, _} = Agents.assign_device_to_agent(agent1.id, device1.id)
targets = Agents.list_agent_polling_targets(agent1.id)
equipment = hd(targets)
device = hd(targets)
# Verify preloads
assert Ecto.assoc_loaded?(equipment.site)
assert Ecto.assoc_loaded?(equipment.site.organization)
assert Ecto.assoc_loaded?(device.site)
assert Ecto.assoc_loaded?(device.site.organization)
end
end
end

View file

@ -35,8 +35,8 @@ defmodule Towerops.Alerts.AlertNotifierTest do
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
site_id: site.id
@ -47,7 +47,7 @@ defmodule Towerops.Alerts.AlertNotifierTest do
assert_email_sent(subject: "Confirmation instructions")
assert_email_sent(subject: "Confirmation instructions")
%{owner: owner, admin: admin, member: member, organization: organization, equipment: equipment}
%{owner: owner, admin: admin, member: member, organization: organization, device: device}
end
describe "deliver_alert_notification/1" do
@ -55,14 +55,14 @@ defmodule Towerops.Alerts.AlertNotifierTest do
owner: owner,
admin: admin,
organization: organization,
equipment: equipment
device: device
} do
{:ok, alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Equipment is down"
message: "Device is down"
})
{:ok, results} = AlertNotifier.deliver_alert_notification(alert)
@ -71,22 +71,22 @@ defmodule Towerops.Alerts.AlertNotifierTest do
assert length(results) == 2
# Verify emails were sent to owner and admin
assert_email_sent(subject: "[#{organization.name}] Equipment Down: #{equipment.name}", to: owner.email)
assert_email_sent(subject: "[#{organization.name}] Equipment Down: #{equipment.name}", to: admin.email)
assert_email_sent(subject: "[#{organization.name}] Device Down: #{device.name}", to: owner.email)
assert_email_sent(subject: "[#{organization.name}] Device Down: #{device.name}", to: admin.email)
end
test "sends equipment_up alert to owners and admins", %{
owner: owner,
admin: admin,
equipment: equipment,
device: device,
organization: organization
} do
{:ok, alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_up,
device_id: device.id,
alert_type: :device_up,
triggered_at: DateTime.utc_now(),
message: "Equipment recovered"
message: "Device recovered"
})
{:ok, results} = AlertNotifier.deliver_alert_notification(alert)
@ -95,21 +95,21 @@ defmodule Towerops.Alerts.AlertNotifierTest do
assert length(results) == 2
# Verify emails were sent
assert_email_sent(subject: "[#{organization.name}] Equipment Recovered: #{equipment.name}", to: owner.email)
assert_email_sent(subject: "[#{organization.name}] Equipment Recovered: #{equipment.name}", to: admin.email)
assert_email_sent(subject: "[#{organization.name}] Device Recovered: #{device.name}", to: owner.email)
assert_email_sent(subject: "[#{organization.name}] Device Recovered: #{device.name}", to: admin.email)
end
test "equipment_down alert includes correct information", %{
owner: owner,
equipment: equipment,
device: device,
organization: organization
} do
{:ok, alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Equipment is down"
message: "Device is down"
})
{:ok, results} = AlertNotifier.deliver_alert_notification(alert)
@ -119,46 +119,46 @@ defmodule Towerops.Alerts.AlertNotifierTest do
# Verify email sent to owner contains all expected information
assert_email_sent(fn email ->
email.to |> List.first() |> elem(1) == owner.email &&
email.subject =~ "Equipment Down" &&
email.subject =~ "Device Down" &&
email.text_body =~ organization.name &&
email.text_body =~ equipment.name &&
email.text_body =~ equipment.ip_address &&
email.text_body =~ device.name &&
email.text_body =~ device.ip_address &&
email.text_body =~ "Test Site" &&
email.text_body =~ "not responding"
end)
end
test "equipment_up alert includes correct information", %{
equipment: equipment,
device: device,
organization: organization
} do
{:ok, alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_up,
device_id: device.id,
alert_type: :device_up,
triggered_at: DateTime.utc_now(),
message: "Equipment recovered"
message: "Device recovered"
})
{:ok, _results} = AlertNotifier.deliver_alert_notification(alert)
assert_email_sent(fn email ->
email.subject =~ "Equipment Recovered" &&
email.subject =~ "Device Recovered" &&
email.text_body =~ organization.name &&
email.text_body =~ equipment.name &&
email.text_body =~ equipment.ip_address &&
email.text_body =~ device.name &&
email.text_body =~ device.ip_address &&
email.text_body =~ "Test Site" &&
email.text_body =~ "now responding"
end)
end
test "uses correct from address", %{equipment: equipment} do
test "uses correct from address", %{device: device} do
{:ok, alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Equipment is down"
message: "Device is down"
})
{:ok, _results} = AlertNotifier.deliver_alert_notification(alert)

View file

@ -18,26 +18,26 @@ defmodule Towerops.AlertsTest do
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
})
%{equipment: equipment, organization: organization, user: user}
%{device: device, organization: organization, user: user}
end
@valid_attrs %{
alert_type: :equipment_down,
alert_type: :device_down,
triggered_at: ~U[2025-12-21 12:00:00Z],
message: "Equipment is not responding"
}
test "create_alert/1 with valid data creates an alert", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
test "create_alert/1 with valid data creates an alert", %{device: device} do
attrs = Map.put(@valid_attrs, :device_id, device.id)
assert {:ok, %Alert{} = alert} = Alerts.create_alert(attrs)
assert alert.alert_type == :equipment_down
assert alert.alert_type == :device_down
assert alert.message == "Equipment is not responding"
end
@ -45,18 +45,18 @@ defmodule Towerops.AlertsTest do
assert {:error, %Ecto.Changeset{}} = Alerts.create_alert(%{})
end
test "list_equipment_alerts/2 returns alerts for equipment", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
test "list_devices_alerts/2 returns alerts for device", %{device: device} do
attrs = Map.put(@valid_attrs, :device_id, device.id)
{:ok, alert} = Alerts.create_alert(attrs)
[result] = Alerts.list_equipment_alerts(equipment.id)
[result] = Alerts.list_devices_alerts(device.id)
assert result.id == alert.id
end
test "list_organization_active_alerts/1 returns unresolved alerts", %{
equipment: equipment,
device: device,
organization: organization
} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
attrs = Map.put(@valid_attrs, :device_id, device.id)
{:ok, alert} = Alerts.create_alert(attrs)
# Create resolved alert
@ -70,25 +70,25 @@ defmodule Towerops.AlertsTest do
end
test "list_organization_alerts/2 returns all alerts", %{
equipment: equipment,
device: device,
organization: organization
} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
attrs = Map.put(@valid_attrs, :device_id, device.id)
{:ok, _alert1} = Alerts.create_alert(attrs)
{:ok, _alert2} = Alerts.create_alert(Map.put(attrs, :alert_type, :equipment_up))
{:ok, _alert2} = Alerts.create_alert(Map.put(attrs, :alert_type, :device_up))
alerts = Alerts.list_organization_alerts(organization.id, 100)
assert length(alerts) == 2
end
test "get_alert!/1 returns the alert with given id", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
test "get_alert!/1 returns the alert with given id", %{device: device} do
attrs = Map.put(@valid_attrs, :device_id, device.id)
{:ok, alert} = Alerts.create_alert(attrs)
assert Alerts.get_alert!(alert.id).id == alert.id
end
test "acknowledge_alert/2 marks alert as acknowledged", %{equipment: equipment, user: user} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
test "acknowledge_alert/2 marks alert as acknowledged", %{device: device, user: user} do
attrs = Map.put(@valid_attrs, :device_id, device.id)
{:ok, alert} = Alerts.create_alert(attrs)
assert {:ok, acknowledged} = Alerts.acknowledge_alert(alert, user.id)
@ -96,48 +96,48 @@ defmodule Towerops.AlertsTest do
assert acknowledged.acknowledged_by_id == user.id
end
test "resolve_alert/1 marks alert as resolved", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
test "resolve_alert/1 marks alert as resolved", %{device: device} do
attrs = Map.put(@valid_attrs, :device_id, device.id)
{:ok, alert} = Alerts.create_alert(attrs)
assert {:ok, resolved} = Alerts.resolve_alert(alert)
assert resolved.resolved_at
end
test "has_active_alert?/2 returns true when active alert exists", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
test "has_active_alert?/2 returns true when active alert exists", %{device: device} do
attrs = Map.put(@valid_attrs, :device_id, device.id)
{:ok, _alert} = Alerts.create_alert(attrs)
assert Alerts.has_active_alert?(equipment.id, :equipment_down) == true
assert Alerts.has_active_alert?(equipment.id, :equipment_up) == false
assert Alerts.has_active_alert?(device.id, :device_down) == true
assert Alerts.has_active_alert?(device.id, :device_up) == false
end
test "has_active_alert?/2 returns false for resolved alerts", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
test "has_active_alert?/2 returns false for resolved alerts", %{device: device} do
attrs = Map.put(@valid_attrs, :device_id, device.id)
{:ok, alert} = Alerts.create_alert(attrs)
{:ok, _resolved} = Alerts.resolve_alert(alert)
assert Alerts.has_active_alert?(equipment.id, :equipment_down) == false
assert Alerts.has_active_alert?(device.id, :device_down) == false
end
test "get_active_alert/2 returns active alert of specific type", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
test "get_active_alert/2 returns active alert of specific type", %{device: device} do
attrs = Map.put(@valid_attrs, :device_id, device.id)
{:ok, alert} = Alerts.create_alert(attrs)
found = Alerts.get_active_alert(equipment.id, :equipment_down)
found = Alerts.get_active_alert(device.id, :device_down)
assert found.id == alert.id
end
test "get_active_alert/2 returns nil when no active alert exists", %{equipment: equipment} do
assert Alerts.get_active_alert(equipment.id, :equipment_down) == nil
test "get_active_alert/2 returns nil when no active alert exists", %{device: device} do
assert Alerts.get_active_alert(device.id, :device_down) == nil
end
test "get_active_alert/2 returns nil for resolved alerts", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
test "get_active_alert/2 returns nil for resolved alerts", %{device: device} do
attrs = Map.put(@valid_attrs, :device_id, device.id)
{:ok, alert} = Alerts.create_alert(attrs)
{:ok, _resolved} = Alerts.resolve_alert(alert)
assert Alerts.get_active_alert(equipment.id, :equipment_down) == nil
assert Alerts.get_active_alert(device.id, :device_down) == nil
end
end
end

View file

@ -0,0 +1,296 @@
defmodule Towerops.EquipmentTest do
use Towerops.DataCase
alias Towerops.Devices
alias Towerops.Devices.Device
describe "device" do
import Towerops.AccountsFixtures
alias Device, as: DeviceSchema
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site, user: user}
end
@valid_attrs %{
name: "Router 1",
ip_address: "192.168.1.1",
description: "Main router",
monitoring_enabled: true,
check_interval_seconds: 300
}
@update_attrs %{
name: "Updated Router",
ip_address: "192.168.1.2",
description: "Updated",
monitoring_enabled: false
}
@invalid_attrs %{name: nil, ip_address: nil}
test "list_site_devices/1 returns all devices for a site", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert Devices.list_site_devices(site.id) == [device]
end
test "list_organization_devices/1 returns all devices for an organization", %{
organization: organization,
site: site
} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
result = Devices.list_organization_devices(organization.id)
assert length(result) == 1
assert hd(result).id == device.id
end
test "list_monitored_devices/0 returns only device with monitoring enabled", %{
site: site
} do
{:ok, monitored} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
{:ok, _not_monitored} =
Devices.create_device(%{
name: "Not Monitored",
ip_address: "192.168.1.3",
site_id: site.id,
monitoring_enabled: false
})
result = Devices.list_monitored_devices()
assert length(result) == 1
assert hd(result).id == monitored.id
end
test "get_device!/1 returns the device with given id", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert Devices.get_device!(device.id).id == device.id
end
test "get_site_equipment!/2 returns device for specific site", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert Devices.get_site_device!(site.id, device.id).id == device.id
end
test "create_device/1 with valid data creates device", %{site: site} do
attrs = Map.put(@valid_attrs, :site_id, site.id)
assert {:ok, %DeviceSchema{} = device} = Devices.create_device(attrs)
assert device.name == "Router 1"
assert device.ip_address == "192.168.1.1"
assert device.status == :unknown
assert device.monitoring_enabled == true
assert device.check_interval_seconds == 300
end
test "create_device/1 with valid IPv6 address", %{site: site} do
attrs = Map.put(@valid_attrs, :ip_address, "2001:0db8:85a3::8a2e:0370:7334")
attrs = Map.put(attrs, :site_id, site.id)
assert {:ok, %DeviceSchema{}} = Devices.create_device(attrs)
end
test "create_device/1 with invalid IP address returns error", %{site: site} do
attrs = Map.put(@valid_attrs, :ip_address, "invalid-ip")
attrs = Map.put(attrs, :site_id, site.id)
assert {:error, changeset} = Devices.create_device(attrs)
assert "must be a valid IPv4 or IPv6 address" in errors_on(changeset).ip_address
end
test "create_device/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Devices.create_device(@invalid_attrs)
end
test "update_device/2 with valid data updates the device", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert {:ok, %DeviceSchema{} = device} =
Devices.update_device(device, @update_attrs)
assert device.name == "Updated Router"
assert device.ip_address == "192.168.1.2"
assert device.monitoring_enabled == false
end
test "update_device/2 with invalid data returns error changeset", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert {:error, %Ecto.Changeset{}} =
Devices.update_device(device, @invalid_attrs)
assert Devices.get_device!(device.id).name == device.name
end
test "delete_device/1 deletes the device", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert {:ok, %DeviceSchema{}} = Devices.delete_device(device)
assert_raise Ecto.NoResultsError, fn -> Devices.get_device!(device.id) end
end
test "change_device/1 returns an device changeset", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert %Ecto.Changeset{} = Devices.change_device(device)
end
test "update_device_status/2 updates status and timestamps", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert {:ok, updated} = Devices.update_device_status(device, :up)
assert updated.status == :up
assert updated.last_checked_at
assert updated.last_status_change_at
end
test "update_device_status/2 only updates last_checked_at if status unchanged", %{
site: site
} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
{:ok, device} = Devices.update_device_status(device, :up)
first_change_at = device.last_status_change_at
first_checked_at = device.last_checked_at
# Sleep for 1+ second to ensure timestamps differ (truncated to seconds in update_device_status)
Process.sleep(1010)
{:ok, updated} = Devices.update_device_status(device, :up)
# Status change timestamp should not change
assert updated.last_status_change_at == first_change_at
# But checked_at should be updated
assert DateTime.after?(updated.last_checked_at, first_checked_at)
end
test "list_snmp_enabled_devices/0 returns only device with SNMP enabled", %{site: site} do
{:ok, snmp_enabled} =
Devices.create_device(%{
name: "SNMP Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: true,
snmp_community: "public"
})
{:ok, _snmp_disabled} =
Devices.create_device(%{
name: "Non-SNMP Router",
ip_address: "192.168.1.2",
site_id: site.id,
snmp_enabled: false
})
result = Devices.list_snmp_enabled_devices()
assert length(result) == 1
assert hd(result).id == snmp_enabled.id
end
test "update_snmp_poll_time/1 updates last_snmp_poll_at timestamp", %{site: site} do
{:ok, device} = Devices.create_device(Map.put(@valid_attrs, :site_id, site.id))
assert device.last_snmp_poll_at == nil
assert {:ok, updated} = Devices.update_snmp_poll_time(device)
assert updated.last_snmp_poll_at
assert DateTime.before?(updated.last_snmp_poll_at, DateTime.utc_now())
end
end
describe "events" do
import Towerops.AccountsFixtures
alias Towerops.Devices.Event
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, device} =
Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
})
%{device: device}
end
test "create_event/1 creates an device event", %{device: device} do
attrs = %{
device_id: device.id,
event_type: "interface_up",
severity: "info",
message: "Interface came online",
metadata: %{"interface" => "eth0"},
occurred_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, %Event{} = event} = Devices.create_event(attrs)
assert event.device_id == device.id
assert event.event_type == "interface_up"
assert event.severity == "info"
assert event.message == "Interface came online"
assert event.metadata == %{"interface" => "eth0"}
end
test "create_event/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Devices.create_event(%{})
end
test "list_devices_events/2 returns events for device ordered by most recent", %{
device: device
} do
now = DateTime.utc_now()
{:ok, event1} =
Devices.create_event(%{
device_id: device.id,
event_type: "interface_up",
severity: "info",
message: "Interface came up",
occurred_at: DateTime.add(now, -3600, :second)
})
{:ok, event2} =
Devices.create_event(%{
device_id: device.id,
event_type: "interface_down",
severity: "warning",
message: "Interface went down",
occurred_at: DateTime.add(now, -1800, :second)
})
events = Devices.list_devices_events(device.id)
assert length(events) == 2
# Should be ordered by most recent first
assert hd(events).id == event2.id
assert List.last(events).id == event1.id
end
test "list_devices_events/2 limits results", %{device: device} do
now = DateTime.utc_now()
# Create 10 events
for i <- 1..10 do
Devices.create_event(%{
device_id: device.id,
event_type: "interface_down",
severity: "warning",
message: "Interface down event #{i}",
occurred_at: DateTime.add(now, -i * 60, :second)
})
end
events = Devices.list_devices_events(device.id, 5)
assert length(events) == 5
end
end
end

View file

@ -1,10 +1,9 @@
defmodule Towerops.Equipment.EventLoggerTest do
defmodule Towerops.Devices.EventLoggerTest do
use Towerops.DataCase, async: false
import Towerops.AccountsFixtures
alias Towerops.Equipment
alias Towerops.Equipment.EventLogger
alias Towerops.Devices.EventLogger
describe "event logging via PubSub" do
setup do
@ -19,19 +18,19 @@ defmodule Towerops.Equipment.EventLoggerTest do
organization_id: organization.id
})
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Equipment",
site_id: site.id,
ip_address: "192.168.1.1"
})
%{user: user, organization: organization, site: site, equipment: equipment}
%{user: user, organization: organization, site: site, device: device}
end
test "EventLogger logs events broadcast via PubSub", %{equipment: equipment} do
test "EventLogger logs events broadcast via PubSub", %{device: device} do
event_attrs = %{
equipment_id: equipment.id,
device_id: device.id,
event_type: "interface_speed_change",
severity: "info",
message: "Interface eth0 speed detected: 1.0 Gbps",
@ -47,35 +46,35 @@ defmodule Towerops.Equipment.EventLoggerTest do
# Broadcast event via PubSub
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:events",
{:equipment_event, event_attrs}
"device:events",
{:device_event, event_attrs}
)
# Give the EventLogger a moment to process
Process.sleep(100)
# Verify event was created in database
events = Equipment.list_equipment_events(equipment.id, 10)
events = Towerops.Devices.list_devices_events(device.id, 10)
assert length(events) == 1
event = hd(events)
assert event.equipment_id == equipment.id
assert event.device_id == device.id
assert event.event_type == "interface_speed_change"
assert event.severity == "info"
assert event.message == "Interface eth0 speed detected: 1.0 Gbps"
end
test "EventLogger handles multiple events in sequence", %{equipment: equipment} do
test "EventLogger handles multiple events in sequence", %{device: device} do
now = DateTime.truncate(DateTime.utc_now(), :second)
# Broadcast multiple events
for i <- 1..3 do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:events",
{:equipment_event,
"device:events",
{:device_event,
%{
equipment_id: equipment.id,
device_id: device.id,
event_type: "interface_speed_change",
severity: "info",
message: "Event #{i}",
@ -89,14 +88,14 @@ defmodule Towerops.Equipment.EventLoggerTest do
Process.sleep(300)
# Verify all events were created
events = Equipment.list_equipment_events(equipment.id, 10)
events = Towerops.Devices.list_devices_events(device.id, 10)
assert length(events) == 3
end
test "EventLogger logs errors for invalid events", %{equipment: equipment} do
test "EventLogger logs errors for invalid events", %{device: device} do
# Broadcast an invalid event (missing required fields)
invalid_event = %{
equipment_id: equipment.id
device_id: device.id
# Missing required fields
}
@ -105,8 +104,8 @@ defmodule Towerops.Equipment.EventLoggerTest do
ExUnit.CaptureLog.capture_log(fn ->
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:events",
{:equipment_event, invalid_event}
"device:events",
{:device_event, invalid_event}
)
Process.sleep(100)
@ -120,7 +119,7 @@ defmodule Towerops.Equipment.EventLoggerTest do
# Send a message that's not an event
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:events",
"device:events",
{:some_other_message, "data"}
)

View file

@ -1,295 +0,0 @@
defmodule Towerops.EquipmentTest do
use Towerops.DataCase
alias Towerops.Equipment
describe "equipment" do
import Towerops.AccountsFixtures
alias Towerops.Equipment.Equipment, as: EquipmentSchema
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site, user: user}
end
@valid_attrs %{
name: "Router 1",
ip_address: "192.168.1.1",
description: "Main router",
monitoring_enabled: true,
check_interval_seconds: 300
}
@update_attrs %{
name: "Updated Router",
ip_address: "192.168.1.2",
description: "Updated",
monitoring_enabled: false
}
@invalid_attrs %{name: nil, ip_address: nil}
test "list_site_equipment/1 returns all equipment for a site", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert Equipment.list_site_equipment(site.id) == [equipment]
end
test "list_organization_equipment/1 returns all equipment for an organization", %{
organization: organization,
site: site
} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
result = Equipment.list_organization_equipment(organization.id)
assert length(result) == 1
assert hd(result).id == equipment.id
end
test "list_monitored_equipment/0 returns only equipment with monitoring enabled", %{
site: site
} do
{:ok, monitored} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
{:ok, _not_monitored} =
Equipment.create_equipment(%{
name: "Not Monitored",
ip_address: "192.168.1.3",
site_id: site.id,
monitoring_enabled: false
})
result = Equipment.list_monitored_equipment()
assert length(result) == 1
assert hd(result).id == monitored.id
end
test "get_equipment!/1 returns the equipment with given id", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert Equipment.get_equipment!(equipment.id).id == equipment.id
end
test "get_site_equipment!/2 returns equipment for specific site", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert Equipment.get_site_equipment!(site.id, equipment.id).id == equipment.id
end
test "create_equipment/1 with valid data creates equipment", %{site: site} do
attrs = Map.put(@valid_attrs, :site_id, site.id)
assert {:ok, %EquipmentSchema{} = equipment} = Equipment.create_equipment(attrs)
assert equipment.name == "Router 1"
assert equipment.ip_address == "192.168.1.1"
assert equipment.status == :unknown
assert equipment.monitoring_enabled == true
assert equipment.check_interval_seconds == 300
end
test "create_equipment/1 with valid IPv6 address", %{site: site} do
attrs = Map.put(@valid_attrs, :ip_address, "2001:0db8:85a3::8a2e:0370:7334")
attrs = Map.put(attrs, :site_id, site.id)
assert {:ok, %EquipmentSchema{}} = Equipment.create_equipment(attrs)
end
test "create_equipment/1 with invalid IP address returns error", %{site: site} do
attrs = Map.put(@valid_attrs, :ip_address, "invalid-ip")
attrs = Map.put(attrs, :site_id, site.id)
assert {:error, changeset} = Equipment.create_equipment(attrs)
assert "must be a valid IPv4 or IPv6 address" in errors_on(changeset).ip_address
end
test "create_equipment/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Equipment.create_equipment(@invalid_attrs)
end
test "update_equipment/2 with valid data updates the equipment", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert {:ok, %EquipmentSchema{} = equipment} =
Equipment.update_equipment(equipment, @update_attrs)
assert equipment.name == "Updated Router"
assert equipment.ip_address == "192.168.1.2"
assert equipment.monitoring_enabled == false
end
test "update_equipment/2 with invalid data returns error changeset", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert {:error, %Ecto.Changeset{}} =
Equipment.update_equipment(equipment, @invalid_attrs)
assert Equipment.get_equipment!(equipment.id).name == equipment.name
end
test "delete_equipment/1 deletes the equipment", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert {:ok, %EquipmentSchema{}} = Equipment.delete_equipment(equipment)
assert_raise Ecto.NoResultsError, fn -> Equipment.get_equipment!(equipment.id) end
end
test "change_equipment/1 returns an equipment changeset", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert %Ecto.Changeset{} = Equipment.change_equipment(equipment)
end
test "update_equipment_status/2 updates status and timestamps", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert {:ok, updated} = Equipment.update_equipment_status(equipment, :up)
assert updated.status == :up
assert updated.last_checked_at
assert updated.last_status_change_at
end
test "update_equipment_status/2 only updates last_checked_at if status unchanged", %{
site: site
} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
{:ok, equipment} = Equipment.update_equipment_status(equipment, :up)
first_change_at = equipment.last_status_change_at
first_checked_at = equipment.last_checked_at
# Sleep for 1+ second to ensure timestamps differ (truncated to seconds in update_equipment_status)
Process.sleep(1010)
{:ok, updated} = Equipment.update_equipment_status(equipment, :up)
# Status change timestamp should not change
assert updated.last_status_change_at == first_change_at
# But checked_at should be updated
assert DateTime.after?(updated.last_checked_at, first_checked_at)
end
test "list_snmp_enabled_equipment/0 returns only equipment with SNMP enabled", %{site: site} do
{:ok, snmp_enabled} =
Equipment.create_equipment(%{
name: "SNMP Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: true,
snmp_community: "public"
})
{:ok, _snmp_disabled} =
Equipment.create_equipment(%{
name: "Non-SNMP Router",
ip_address: "192.168.1.2",
site_id: site.id,
snmp_enabled: false
})
result = Equipment.list_snmp_enabled_equipment()
assert length(result) == 1
assert hd(result).id == snmp_enabled.id
end
test "update_snmp_poll_time/1 updates last_snmp_poll_at timestamp", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert equipment.last_snmp_poll_at == nil
assert {:ok, updated} = Equipment.update_snmp_poll_time(equipment)
assert updated.last_snmp_poll_at
assert DateTime.before?(updated.last_snmp_poll_at, DateTime.utc_now())
end
end
describe "events" do
import Towerops.AccountsFixtures
alias Towerops.Equipment.Event
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, equipment} =
Equipment.create_equipment(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
})
%{equipment: equipment}
end
test "create_event/1 creates an equipment event", %{equipment: equipment} do
attrs = %{
equipment_id: equipment.id,
event_type: "interface_up",
severity: "info",
message: "Interface came online",
metadata: %{"interface" => "eth0"},
occurred_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, %Event{} = event} = Equipment.create_event(attrs)
assert event.equipment_id == equipment.id
assert event.event_type == "interface_up"
assert event.severity == "info"
assert event.message == "Interface came online"
assert event.metadata == %{"interface" => "eth0"}
end
test "create_event/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Equipment.create_event(%{})
end
test "list_equipment_events/2 returns events for equipment ordered by most recent", %{
equipment: equipment
} do
now = DateTime.utc_now()
{:ok, event1} =
Equipment.create_event(%{
equipment_id: equipment.id,
event_type: "interface_up",
severity: "info",
message: "Interface came up",
occurred_at: DateTime.add(now, -3600, :second)
})
{:ok, event2} =
Equipment.create_event(%{
equipment_id: equipment.id,
event_type: "interface_down",
severity: "warning",
message: "Interface went down",
occurred_at: DateTime.add(now, -1800, :second)
})
events = Equipment.list_equipment_events(equipment.id)
assert length(events) == 2
# Should be ordered by most recent first
assert hd(events).id == event2.id
assert List.last(events).id == event1.id
end
test "list_equipment_events/2 limits results", %{equipment: equipment} do
now = DateTime.utc_now()
# Create 10 events
for i <- 1..10 do
Equipment.create_event(%{
equipment_id: equipment.id,
event_type: "interface_down",
severity: "warning",
message: "Interface down event #{i}",
occurred_at: DateTime.add(now, -i * 60, :second)
})
end
events = Equipment.list_equipment_events(equipment.id, 5)
assert length(events) == 5
end
end
end

View file

@ -0,0 +1,207 @@
defmodule Towerops.Monitoring.DeviceMonitorTest do
use Towerops.DataCase, async: false
import Mox
import Towerops.AccountsFixtures
alias Towerops.Alerts
alias Towerops.Monitoring
alias Towerops.Monitoring.DeviceMonitor
alias Towerops.Monitoring.PingMock
# Set up Mox to verify expectations on exit
setup :verify_on_exit!
setup :set_mox_from_context
setup do
# Stub default ping behavior using the PingStub module
Mox.stub_with(PingMock, Towerops.Monitoring.PingStub)
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "127.0.0.1",
site_id: site.id,
monitoring_enabled: true,
check_interval_seconds: 1
})
%{device: device}
end
describe "init/1" do
test "initializes with device_id and schedules check if monitoring enabled", %{
device: device
} do
{:ok, state} = DeviceMonitor.init(device.id)
assert state.device_id == device.id
end
test "initializes but doesn't schedule if monitoring disabled", %{device: device} do
{:ok, updated} = Towerops.Devices.update_device(device, %{monitoring_enabled: false})
{:ok, state} = DeviceMonitor.init(updated.id)
assert state.device_id == updated.id
end
end
describe "trigger_check/1" do
test "can trigger an immediate check", %{device: device} do
# Start the monitor
start_supervised!({DeviceMonitor, device.id})
# Trigger a check
DeviceMonitor.trigger_check(device.id)
# Give it time to process (GenServer needs time to handle the message)
Process.sleep(200)
# Should have created a monitoring check
checks = Monitoring.list_devices_checks(device.id)
refute Enum.empty?(checks)
end
end
describe "handle_info :check_device" do
test "performs check and creates monitoring record", %{device: device} do
{:ok, state} = DeviceMonitor.init(device.id)
# Simulate the periodic check message
{:noreply, _new_state} = DeviceMonitor.handle_info(:check_device, state)
# Should have created a check
checks = Monitoring.list_devices_checks(device.id)
assert length(checks) == 1
end
end
describe "SNMP monitoring configuration" do
test "equipment can be configured with SNMP settings", %{device: device} do
{:ok, updated_device} =
Towerops.Devices.update_device(device, %{
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
})
assert updated_device.snmp_enabled == true
assert updated_device.snmp_version == "2c"
assert updated_device.snmp_community == "public"
assert updated_device.snmp_port == 161
end
test "SNMP version defaults to 2c", %{device: device} do
{:ok, updated_device} =
Towerops.Devices.update_device(device, %{
snmp_enabled: true,
snmp_community: "public"
})
assert updated_device.snmp_version == "2c"
end
end
describe "alert creation" do
setup %{device: device} do
# Set equipment to up status first
{:ok, device} = Towerops.Devices.update_device_status(device, :up)
%{device: device}
end
test "creates device_down alert when equipment goes down", %{device: device} do
# Mock failed ping
expect(PingMock, :ping, fn _ip ->
{:error, :timeout_or_unreachable}
end)
{:ok, state} = DeviceMonitor.init(device.id)
# Trigger check
{:noreply, _state} = DeviceMonitor.handle_info(:check_device, state)
# Should create an alert (no need to sleep since test env doesn't send emails)
alerts = Alerts.list_devices_alerts(device.id)
refute Enum.empty?(alerts)
alert = hd(alerts)
assert alert.alert_type == :device_down
end
test "creates device_up alert when equipment recovers", %{device: device} do
# First set to down
{:ok, device} = Towerops.Devices.update_device_status(device, :down)
# Create a down alert
{:ok, _} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.truncate(DateTime.utc_now(), :second),
message: "Equipment is down"
})
{:ok, state} = DeviceMonitor.init(device.id)
# Trigger check
{:noreply, _state} = DeviceMonitor.handle_info(:check_device, state)
# Should create recovery alert
alerts = Alerts.list_devices_alerts(device.id, 10)
up_alerts = Enum.filter(alerts, &(&1.alert_type == :device_up))
refute Enum.empty?(up_alerts)
end
test "does not create duplicate down alerts", %{device: device} do
# Mock failed ping for both calls
expect(PingMock, :ping, 2, fn _ip ->
{:error, :timeout_or_unreachable}
end)
{:ok, state} = DeviceMonitor.init(device.id)
# Trigger check twice
{:noreply, _} = DeviceMonitor.handle_info(:check_device, state)
{:noreply, _} = DeviceMonitor.handle_info(:check_device, state)
# Should only have one alert
alerts =
device.id
|> Alerts.list_devices_alerts()
|> Enum.filter(&(&1.alert_type == :device_down && is_nil(&1.resolved_at)))
assert length(alerts) == 1
end
test "resolves down alert when equipment comes back up", %{device: device} do
# Set to down first
{:ok, device} = Towerops.Devices.update_device_status(device, :down)
# Create down alert
{:ok, down_alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.truncate(DateTime.utc_now(), :second),
message: "Down"
})
{:ok, state} = DeviceMonitor.init(device.id)
# Check
{:noreply, _} = DeviceMonitor.handle_info(:check_device, state)
# Down alert should be resolved
updated_alert = Alerts.get_alert!(down_alert.id)
assert updated_alert.resolved_at
end
end
end

View file

@ -1,208 +0,0 @@
defmodule Towerops.Monitoring.EquipmentMonitorTest do
use Towerops.DataCase, async: false
import Mox
import Towerops.AccountsFixtures
alias Towerops.Alerts
alias Towerops.Equipment
alias Towerops.Monitoring
alias Towerops.Monitoring.EquipmentMonitor
alias Towerops.Monitoring.PingMock
# Set up Mox to verify expectations on exit
setup :verify_on_exit!
setup :set_mox_from_context
setup do
# Stub default ping behavior using the PingStub module
Mox.stub_with(PingMock, Towerops.Monitoring.PingStub)
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, equipment} =
Equipment.create_equipment(%{
name: "Router 1",
ip_address: "127.0.0.1",
site_id: site.id,
monitoring_enabled: true,
check_interval_seconds: 1
})
%{equipment: equipment}
end
describe "init/1" do
test "initializes with equipment_id and schedules check if monitoring enabled", %{
equipment: equipment
} do
{:ok, state} = EquipmentMonitor.init(equipment.id)
assert state.equipment_id == equipment.id
end
test "initializes but doesn't schedule if monitoring disabled", %{equipment: equipment} do
{:ok, updated} = Equipment.update_equipment(equipment, %{monitoring_enabled: false})
{:ok, state} = EquipmentMonitor.init(updated.id)
assert state.equipment_id == updated.id
end
end
describe "trigger_check/1" do
test "can trigger an immediate check", %{equipment: equipment} do
# Start the monitor
start_supervised!({EquipmentMonitor, equipment.id})
# Trigger a check
EquipmentMonitor.trigger_check(equipment.id)
# Give it time to process (GenServer needs time to handle the message)
Process.sleep(200)
# Should have created a monitoring check
checks = Monitoring.list_equipment_checks(equipment.id)
refute Enum.empty?(checks)
end
end
describe "handle_info :check_equipment" do
test "performs check and creates monitoring record", %{equipment: equipment} do
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Simulate the periodic check message
{:noreply, _new_state} = EquipmentMonitor.handle_info(:check_equipment, state)
# Should have created a check
checks = Monitoring.list_equipment_checks(equipment.id)
assert length(checks) == 1
end
end
describe "SNMP monitoring configuration" do
test "equipment can be configured with SNMP settings", %{equipment: equipment} do
{:ok, updated_equipment} =
Equipment.update_equipment(equipment, %{
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
})
assert updated_equipment.snmp_enabled == true
assert updated_equipment.snmp_version == "2c"
assert updated_equipment.snmp_community == "public"
assert updated_equipment.snmp_port == 161
end
test "SNMP version defaults to 2c", %{equipment: equipment} do
{:ok, updated_equipment} =
Equipment.update_equipment(equipment, %{
snmp_enabled: true,
snmp_community: "public"
})
assert updated_equipment.snmp_version == "2c"
end
end
describe "alert creation" do
setup %{equipment: equipment} do
# Set equipment to up status first
{:ok, equipment} = Equipment.update_equipment_status(equipment, :up)
%{equipment: equipment}
end
test "creates equipment_down alert when equipment goes down", %{equipment: equipment} do
# Mock failed ping
expect(PingMock, :ping, fn _ip ->
{:error, :timeout_or_unreachable}
end)
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Trigger check
{:noreply, _state} = EquipmentMonitor.handle_info(:check_equipment, state)
# Should create an alert (no need to sleep since test env doesn't send emails)
alerts = Alerts.list_equipment_alerts(equipment.id)
refute Enum.empty?(alerts)
alert = hd(alerts)
assert alert.alert_type == :equipment_down
end
test "creates equipment_up alert when equipment recovers", %{equipment: equipment} do
# First set to down
{:ok, equipment} = Equipment.update_equipment_status(equipment, :down)
# Create a down alert
{:ok, _} =
Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
triggered_at: DateTime.truncate(DateTime.utc_now(), :second),
message: "Equipment is down"
})
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Trigger check
{:noreply, _state} = EquipmentMonitor.handle_info(:check_equipment, state)
# Should create recovery alert
alerts = Alerts.list_equipment_alerts(equipment.id, 10)
up_alerts = Enum.filter(alerts, &(&1.alert_type == :equipment_up))
refute Enum.empty?(up_alerts)
end
test "does not create duplicate down alerts", %{equipment: equipment} do
# Mock failed ping for both calls
expect(PingMock, :ping, 2, fn _ip ->
{:error, :timeout_or_unreachable}
end)
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Trigger check twice
{:noreply, _} = EquipmentMonitor.handle_info(:check_equipment, state)
{:noreply, _} = EquipmentMonitor.handle_info(:check_equipment, state)
# Should only have one alert
alerts =
equipment.id
|> Alerts.list_equipment_alerts()
|> Enum.filter(&(&1.alert_type == :equipment_down && is_nil(&1.resolved_at)))
assert length(alerts) == 1
end
test "resolves down alert when equipment comes back up", %{equipment: equipment} do
# Set to down first
{:ok, equipment} = Equipment.update_equipment_status(equipment, :down)
# Create down alert
{:ok, down_alert} =
Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
triggered_at: DateTime.truncate(DateTime.utc_now(), :second),
message: "Down"
})
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Check
{:noreply, _} = EquipmentMonitor.handle_info(:check_equipment, state)
# Down alert should be resolved
updated_alert = Alerts.get_alert!(down_alert.id)
assert updated_alert.resolved_at
end
end
end

View file

@ -16,117 +16,117 @@ defmodule Towerops.Monitoring.SupervisorTest do
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
monitoring_enabled: true
})
%{equipment: equipment, site: site}
%{device: device, site: site}
end
describe "start_monitor/1" do
test "starts a monitor for equipment", %{equipment: equipment} do
assert {:ok, pid} = MonitoringSupervisor.start_monitor(equipment.id)
test "starts a monitor for device", %{device: device} do
assert {:ok, pid} = MonitoringSupervisor.start_monitor(device.id)
assert is_pid(pid)
assert Process.alive?(pid)
# Cleanup
MonitoringSupervisor.stop_monitor(equipment.id)
MonitoringSupervisor.stop_monitor(device.id)
end
test "returns error when monitor already running", %{equipment: equipment} do
{:ok, _pid} = MonitoringSupervisor.start_monitor(equipment.id)
test "returns error when monitor already running", %{device: device} do
{:ok, _pid} = MonitoringSupervisor.start_monitor(device.id)
assert {:error, {:already_started, _pid}} =
MonitoringSupervisor.start_monitor(equipment.id)
MonitoringSupervisor.start_monitor(device.id)
# Cleanup
MonitoringSupervisor.stop_monitor(equipment.id)
MonitoringSupervisor.stop_monitor(device.id)
end
end
describe "stop_monitor/1" do
test "stops a running monitor", %{equipment: equipment} do
{:ok, pid} = MonitoringSupervisor.start_monitor(equipment.id)
test "stops a running monitor", %{device: device} do
{:ok, pid} = MonitoringSupervisor.start_monitor(device.id)
assert Process.alive?(pid)
assert :ok = MonitoringSupervisor.stop_monitor(equipment.id)
assert :ok = MonitoringSupervisor.stop_monitor(device.id)
# Give it a moment to terminate
Process.sleep(10)
refute Process.alive?(pid)
end
test "returns :ok when monitor not running", %{equipment: equipment} do
assert :ok = MonitoringSupervisor.stop_monitor(equipment.id)
test "returns :ok when monitor not running", %{device: device} do
assert :ok = MonitoringSupervisor.stop_monitor(device.id)
end
end
describe "start_snmp_poller/1" do
test "starts an SNMP poller for equipment", %{equipment: equipment} do
test "starts an SNMP poller for device", %{device: device} do
# Enable SNMP for the equipment
{:ok, equipment} =
Towerops.Equipment.update_equipment(equipment, %{
{:ok, device} =
Towerops.Devices.update_device(device, %{
snmp_enabled: true,
snmp_community: "public"
})
assert {:ok, pid} = MonitoringSupervisor.start_snmp_poller(equipment.id)
assert {:ok, pid} = MonitoringSupervisor.start_snmp_poller(device.id)
assert is_pid(pid)
assert Process.alive?(pid)
# Cleanup
MonitoringSupervisor.stop_snmp_poller(equipment.id)
MonitoringSupervisor.stop_snmp_poller(device.id)
end
test "returns error when poller already running", %{equipment: equipment} do
{:ok, equipment} =
Towerops.Equipment.update_equipment(equipment, %{
test "returns error when poller already running", %{device: device} do
{:ok, device} =
Towerops.Devices.update_device(device, %{
snmp_enabled: true,
snmp_community: "public"
})
{:ok, _pid} = MonitoringSupervisor.start_snmp_poller(equipment.id)
{:ok, _pid} = MonitoringSupervisor.start_snmp_poller(device.id)
assert {:error, {:already_started, _pid}} =
MonitoringSupervisor.start_snmp_poller(equipment.id)
MonitoringSupervisor.start_snmp_poller(device.id)
# Cleanup
MonitoringSupervisor.stop_snmp_poller(equipment.id)
MonitoringSupervisor.stop_snmp_poller(device.id)
end
end
describe "stop_snmp_poller/1" do
test "stops a running SNMP poller", %{equipment: equipment} do
{:ok, equipment} =
Towerops.Equipment.update_equipment(equipment, %{
test "stops a running SNMP poller", %{device: device} do
{:ok, device} =
Towerops.Devices.update_device(device, %{
snmp_enabled: true,
snmp_community: "public"
})
{:ok, pid} = MonitoringSupervisor.start_snmp_poller(equipment.id)
{:ok, pid} = MonitoringSupervisor.start_snmp_poller(device.id)
assert Process.alive?(pid)
assert :ok = MonitoringSupervisor.stop_snmp_poller(equipment.id)
assert :ok = MonitoringSupervisor.stop_snmp_poller(device.id)
# Give it a moment to terminate
Process.sleep(10)
refute Process.alive?(pid)
end
test "returns :ok when poller not running", %{equipment: equipment} do
assert :ok = MonitoringSupervisor.stop_snmp_poller(equipment.id)
test "returns :ok when poller not running", %{device: device} do
assert :ok = MonitoringSupervisor.stop_snmp_poller(device.id)
end
end
describe "start_all_monitors/0" do
test "starts monitors for all monitored equipment", %{equipment: equipment, site: site} do
# Create another monitored equipment
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
test "starts monitors for all monitored devices", %{device: device, site: site} do
# Create another monitored device
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
@ -137,29 +137,29 @@ defmodule Towerops.Monitoring.SupervisorTest do
MonitoringSupervisor.start_all_monitors()
# Check that both are running
assert [{pid1, _}] = Registry.lookup(Towerops.Monitoring.Registry, equipment.id)
assert [{pid2, _}] = Registry.lookup(Towerops.Monitoring.Registry, equipment2.id)
assert [{pid1, _}] = Registry.lookup(Towerops.Monitoring.Registry, device.id)
assert [{pid2, _}] = Registry.lookup(Towerops.Monitoring.Registry, device2.id)
assert Process.alive?(pid1)
assert Process.alive?(pid2)
# Cleanup
MonitoringSupervisor.stop_monitor(equipment.id)
MonitoringSupervisor.stop_monitor(equipment2.id)
MonitoringSupervisor.stop_monitor(device.id)
MonitoringSupervisor.stop_monitor(device2.id)
end
end
describe "start_all_snmp_pollers/0" do
test "starts pollers for all SNMP-enabled equipment", %{equipment: equipment, site: site} do
# Enable SNMP on equipment
test "starts pollers for all SNMP-enabled devices", %{device: device, site: site} do
# Enable SNMP on device
{:ok, _} =
Towerops.Equipment.update_equipment(equipment, %{
Towerops.Devices.update_device(device, %{
snmp_enabled: true,
snmp_community: "public"
})
# Create another SNMP-enabled equipment
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
# Create another SNMP-enabled device
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
@ -171,14 +171,14 @@ defmodule Towerops.Monitoring.SupervisorTest do
MonitoringSupervisor.start_all_snmp_pollers()
# Check that both are running
assert [{pid1, _}] = Registry.lookup(PollerRegistry, equipment.id)
assert [{pid2, _}] = Registry.lookup(PollerRegistry, equipment2.id)
assert [{pid1, _}] = Registry.lookup(PollerRegistry, device.id)
assert [{pid2, _}] = Registry.lookup(PollerRegistry, device2.id)
assert Process.alive?(pid1)
assert Process.alive?(pid2)
# Cleanup
MonitoringSupervisor.stop_snmp_poller(equipment.id)
MonitoringSupervisor.stop_snmp_poller(equipment2.id)
MonitoringSupervisor.stop_snmp_poller(device.id)
MonitoringSupervisor.stop_snmp_poller(device2.id)
end
end
end

View file

@ -18,14 +18,14 @@ defmodule Towerops.MonitoringTest do
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
})
%{equipment: equipment}
%{device: device}
end
@valid_attrs %{
@ -34,8 +34,8 @@ defmodule Towerops.MonitoringTest do
checked_at: ~U[2025-12-21 12:00:00Z]
}
test "create_check/1 with valid data creates a check", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
test "create_check/1 with valid data creates a check", %{device: device} do
attrs = Map.put(@valid_attrs, :device_id, device.id)
assert {:ok, %Check{} = check} = Monitoring.create_check(attrs)
assert check.status == :success
assert check.response_time_ms == 50
@ -45,57 +45,57 @@ defmodule Towerops.MonitoringTest do
assert {:error, %Ecto.Changeset{}} = Monitoring.create_check(%{})
end
test "list_equipment_checks/2 returns checks for equipment", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
test "list_devices_checks/2 returns checks for device", %{device: device} do
attrs = Map.put(@valid_attrs, :device_id, device.id)
{:ok, check} = Monitoring.create_check(attrs)
assert Monitoring.list_equipment_checks(equipment.id) == [check]
assert Monitoring.list_devices_checks(device.id) == [check]
end
test "list_equipment_checks/2 limits results", %{equipment: equipment} do
test "list_devices_checks/2 limits results", %{device: device} do
# Create 10 checks
for i <- 1..10 do
attrs =
@valid_attrs
|> Map.put(:equipment_id, equipment.id)
|> Map.put(:device_id, device.id)
|> Map.put(:checked_at, DateTime.add(~U[2025-12-21 12:00:00Z], i, :second))
{:ok, _check} = Monitoring.create_check(attrs)
end
assert length(Monitoring.list_equipment_checks(equipment.id, 5)) == 5
assert length(Monitoring.list_devices_checks(device.id, 5)) == 5
end
test "get_latest_check/1 returns most recent check", %{equipment: equipment} do
test "get_latest_check/1 returns most recent check", %{device: device} do
attrs1 =
@valid_attrs
|> Map.put(:equipment_id, equipment.id)
|> Map.put(:device_id, device.id)
|> Map.put(:checked_at, ~U[2025-12-21 12:00:00Z])
attrs2 =
@valid_attrs
|> Map.put(:equipment_id, equipment.id)
|> Map.put(:device_id, device.id)
|> Map.put(:checked_at, ~U[2025-12-21 13:00:00Z])
{:ok, _check1} = Monitoring.create_check(attrs1)
{:ok, check2} = Monitoring.create_check(attrs2)
latest = Monitoring.get_latest_check(equipment.id)
latest = Monitoring.get_latest_check(device.id)
assert latest.id == check2.id
end
test "get_latest_check/1 returns nil when no checks exist", %{equipment: equipment} do
assert Monitoring.get_latest_check(equipment.id) == nil
test "get_latest_check/1 returns nil when no checks exist", %{device: device} do
assert Monitoring.get_latest_check(device.id) == nil
end
test "delete_old_checks/1 deletes checks older than date", %{equipment: equipment} do
test "delete_old_checks/1 deletes checks older than date", %{device: device} do
old_attrs =
@valid_attrs
|> Map.put(:equipment_id, equipment.id)
|> Map.put(:device_id, device.id)
|> Map.put(:checked_at, ~U[2025-01-01 12:00:00Z])
new_attrs =
@valid_attrs
|> Map.put(:equipment_id, equipment.id)
|> Map.put(:device_id, device.id)
|> Map.put(:checked_at, ~U[2025-12-21 12:00:00Z])
{:ok, _old_check} = Monitoring.create_check(old_attrs)
@ -103,16 +103,16 @@ defmodule Towerops.MonitoringTest do
{deleted, _} = Monitoring.delete_old_checks(~U[2025-06-01 00:00:00Z])
assert deleted == 1
assert length(Monitoring.list_equipment_checks(equipment.id)) == 1
assert length(Monitoring.list_devices_checks(device.id)) == 1
end
test "get_hourly_stats/3 returns stats for equipment", %{equipment: equipment} do
test "get_hourly_stats/3 returns stats for device", %{device: device} do
base_time = ~U[2025-12-21 12:00:00Z]
# Create checks in two different hours
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 50,
checked_at: base_time
@ -120,7 +120,7 @@ defmodule Towerops.MonitoringTest do
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 100,
checked_at: DateTime.add(base_time, 30 * 60, :second)
@ -128,7 +128,7 @@ defmodule Towerops.MonitoringTest do
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :failure,
response_time_ms: nil,
checked_at: DateTime.add(base_time, 3600, :second)
@ -137,7 +137,7 @@ defmodule Towerops.MonitoringTest do
start_time = DateTime.add(base_time, -3600, :second)
end_time = DateTime.add(base_time, 7200, :second)
result = Monitoring.get_hourly_stats(equipment.id, start_time, end_time)
result = Monitoring.get_hourly_stats(device.id, start_time, end_time)
assert %Postgrex.Result{} = result
assert length(result.rows) == 2
@ -153,14 +153,14 @@ defmodule Towerops.MonitoringTest do
assert uptime == Decimal.new("100.00")
end
test "get_daily_stats/3 returns stats for equipment", %{equipment: equipment} do
test "get_daily_stats/3 returns stats for device", %{device: device} do
day1 = ~U[2025-12-20 12:00:00Z]
day2 = ~U[2025-12-21 12:00:00Z]
# Create checks on two different days
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 50,
checked_at: day1
@ -168,7 +168,7 @@ defmodule Towerops.MonitoringTest do
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 60,
checked_at: DateTime.add(day1, 3600, :second)
@ -176,7 +176,7 @@ defmodule Towerops.MonitoringTest do
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :failure,
response_time_ms: nil,
checked_at: day2
@ -184,7 +184,7 @@ defmodule Towerops.MonitoringTest do
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 80,
checked_at: DateTime.add(day2, 3600, :second)
@ -193,7 +193,7 @@ defmodule Towerops.MonitoringTest do
start_time = DateTime.add(day1, -24 * 60 * 60, :second)
end_time = DateTime.add(day2, 24 * 60 * 60, :second)
result = Monitoring.get_daily_stats(equipment.id, start_time, end_time)
result = Monitoring.get_daily_stats(device.id, start_time, end_time)
assert %Postgrex.Result{} = result
assert length(result.rows) == 2
@ -207,13 +207,13 @@ defmodule Towerops.MonitoringTest do
assert uptime == Decimal.new("100.00")
end
test "get_uptime_percentage/1 returns uptime percentage", %{equipment: equipment} do
test "get_uptime_percentage/1 returns uptime percentage", %{device: device} do
base_time = DateTime.utc_now()
# Create 3 successful and 1 failed check
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 50,
checked_at: DateTime.add(base_time, -60, :second)
@ -221,7 +221,7 @@ defmodule Towerops.MonitoringTest do
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 60,
checked_at: DateTime.add(base_time, -120, :second)
@ -229,7 +229,7 @@ defmodule Towerops.MonitoringTest do
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :failure,
response_time_ms: nil,
checked_at: DateTime.add(base_time, -180, :second)
@ -237,23 +237,23 @@ defmodule Towerops.MonitoringTest do
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 70,
checked_at: DateTime.add(base_time, -240, :second)
})
result = Monitoring.get_uptime_percentage(equipment.id)
result = Monitoring.get_uptime_percentage(device.id)
assert result == 75.0
end
test "get_uptime_percentage/2 accepts custom days parameter", %{equipment: equipment} do
test "get_uptime_percentage/2 accepts custom days parameter", %{device: device} do
base_time = DateTime.utc_now()
# Create checks within the last 7 days
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 50,
checked_at: DateTime.add(base_time, -3 * 24 * 60 * 60, :second)
@ -261,7 +261,7 @@ defmodule Towerops.MonitoringTest do
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 60,
checked_at: DateTime.add(base_time, -5 * 24 * 60 * 60, :second)
@ -270,18 +270,18 @@ defmodule Towerops.MonitoringTest do
# Create a check older than 7 days (should not be included)
{:ok, _} =
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :failure,
response_time_ms: nil,
checked_at: DateTime.add(base_time, -10 * 24 * 60 * 60, :second)
})
result = Monitoring.get_uptime_percentage(equipment.id, 7)
result = Monitoring.get_uptime_percentage(device.id, 7)
assert result == 100.0
end
test "get_uptime_percentage/1 returns nil when no checks exist", %{equipment: equipment} do
result = Monitoring.get_uptime_percentage(equipment.id)
test "get_uptime_percentage/1 returns nil when no checks exist", %{device: device} do
result = Monitoring.get_uptime_percentage(device.id)
assert is_nil(result)
end
end

View file

@ -12,7 +12,7 @@ defmodule Towerops.Organizations.PolicyTest do
assert Policy.can?(membership, :edit, :organization) == true
assert Policy.can?(membership, :manage, :members) == true
assert Policy.can?(membership, :create, :site) == true
assert Policy.can?(membership, :edit, :equipment) == true
assert Policy.can?(membership, :edit, :device) == true
assert Policy.can?(membership, :view, :anything) == true
end
@ -23,11 +23,11 @@ defmodule Towerops.Organizations.PolicyTest do
assert Policy.can?(membership, :edit, :organization) == true
assert Policy.can?(membership, :manage, :members) == true
assert Policy.can?(membership, :create, :site) == true
assert Policy.can?(membership, :edit, :equipment) == true
assert Policy.can?(membership, :edit, :device) == true
assert Policy.can?(membership, :view, :anything) == true
end
test "member can create and edit sites/equipment but not manage org" do
test "member can create and edit sites/devices but not manage org" do
membership = %Membership{role: :member}
assert Policy.can?(membership, :view, :organization) == true
@ -35,8 +35,8 @@ defmodule Towerops.Organizations.PolicyTest do
assert Policy.can?(membership, :delete, :organization) == false
assert Policy.can?(membership, :create, :site) == true
assert Policy.can?(membership, :edit, :site) == true
assert Policy.can?(membership, :edit, :equipment) == true
assert Policy.can?(membership, :view, :equipment) == true
assert Policy.can?(membership, :edit, :device) == true
assert Policy.can?(membership, :view, :device) == true
assert Policy.can?(membership, :delete, :site) == false
end
@ -47,9 +47,9 @@ defmodule Towerops.Organizations.PolicyTest do
assert Policy.can?(membership, :edit, :organization) == false
assert Policy.can?(membership, :manage, :members) == false
assert Policy.can?(membership, :create, :site) == false
assert Policy.can?(membership, :edit, :equipment) == false
assert Policy.can?(membership, :edit, :device) == false
assert Policy.can?(membership, :view, :site) == true
assert Policy.can?(membership, :view, :equipment) == true
assert Policy.can?(membership, :view, :device) == true
end
test "viewer can acknowledge alerts" do
@ -69,7 +69,7 @@ defmodule Towerops.Organizations.PolicyTest do
test "nil membership cannot do anything" do
assert Policy.can?(nil, :view, :organization) == false
assert Policy.can?(nil, :edit, :site) == false
assert Policy.can?(nil, :delete, :equipment) == false
assert Policy.can?(nil, :delete, :device) == false
end
test "member cannot manage memberships" do
@ -90,7 +90,7 @@ defmodule Towerops.Organizations.PolicyTest do
membership = %Membership{role: :member}
assert Policy.can?(membership, :list, :site) == true
assert Policy.can?(membership, :list, :equipment) == true
assert Policy.can?(membership, :list, :device) == true
assert Policy.can?(membership, :list, :alert) == true
end
@ -106,7 +106,7 @@ defmodule Towerops.Organizations.PolicyTest do
membership = %Membership{role: :viewer}
assert Policy.can?(membership, :list, :site) == true
assert Policy.can?(membership, :list, :equipment) == true
assert Policy.can?(membership, :list, :device) == true
assert Policy.can?(membership, :list, :alert) == true
assert Policy.can?(membership, :list, :organization) == true
end

View file

@ -2,7 +2,7 @@ defmodule Towerops.Proto.AgentPbTest do
use ExUnit.Case, async: true
alias Towerops.Agent.AgentConfig
alias Towerops.Agent.Equipment
alias Towerops.Agent.Device
alias Towerops.Agent.HeartbeatMetadata
alias Towerops.Agent.HeartbeatResponse
alias Towerops.Agent.Interface
@ -18,22 +18,22 @@ defmodule Towerops.Proto.AgentPbTest do
config = %AgentConfig{}
assert config.version == ""
assert config.poll_interval_seconds == 0
assert config.equipment == []
assert config.devices == []
end
test "creates struct with values" do
equipment = %Equipment{id: "eq1", name: "Router"}
device = %Device{id: "eq1", name: "Router"}
config = %AgentConfig{
version: "1.0.0",
poll_interval_seconds: 60,
equipment: [equipment]
devices: [device]
}
assert config.version == "1.0.0"
assert config.poll_interval_seconds == 60
assert length(config.equipment) == 1
assert hd(config.equipment).id == "eq1"
assert length(config.devices) == 1
assert hd(config.devices).id == "eq1"
end
test "encodes and decodes" do
@ -51,16 +51,16 @@ defmodule Towerops.Proto.AgentPbTest do
end
end
describe "Equipment" do
describe "Device" do
test "creates struct with default values" do
equipment = %Equipment{}
assert equipment.id == ""
assert equipment.name == ""
assert equipment.ip_address == ""
assert equipment.snmp == nil
assert equipment.poll_interval_seconds == 0
assert equipment.sensors == []
assert equipment.interfaces == []
device = %Device{}
assert device.id == ""
assert device.name == ""
assert device.ip_address == ""
assert device.snmp == nil
assert device.poll_interval_seconds == 0
assert device.sensors == []
assert device.interfaces == []
end
test "creates struct with SNMP config" do
@ -71,7 +71,7 @@ defmodule Towerops.Proto.AgentPbTest do
port: 161
}
equipment = %Equipment{
device = %Device{
id: "eq1",
name: "Router 1",
ip_address: "192.168.1.1",
@ -79,38 +79,38 @@ defmodule Towerops.Proto.AgentPbTest do
poll_interval_seconds: 60
}
assert equipment.id == "eq1"
assert equipment.name == "Router 1"
assert equipment.ip_address == "192.168.1.1"
assert equipment.snmp.enabled == true
assert equipment.poll_interval_seconds == 60
assert device.id == "eq1"
assert device.name == "Router 1"
assert device.ip_address == "192.168.1.1"
assert device.snmp.enabled == true
assert device.poll_interval_seconds == 60
end
test "creates struct with sensors and interfaces" do
sensor = %Sensor{id: "s1", type: "temperature", oid: "1.3.6.1.2.1"}
interface = %Interface{id: "i1", if_index: 1, if_name: "eth0"}
equipment = %Equipment{
device = %Device{
id: "eq1",
sensors: [sensor],
interfaces: [interface]
}
assert length(equipment.sensors) == 1
assert length(equipment.interfaces) == 1
assert hd(equipment.sensors).id == "s1"
assert hd(equipment.interfaces).id == "i1"
assert length(device.sensors) == 1
assert length(device.interfaces) == 1
assert hd(device.sensors).id == "s1"
assert hd(device.interfaces).id == "i1"
end
test "encodes and decodes" do
equipment = %Equipment{
device = %Device{
id: "eq1",
name: "Router 1",
ip_address: "192.168.1.1"
}
encoded = Equipment.encode(equipment)
decoded = Equipment.decode(encoded)
encoded = Device.encode(device)
decoded = Device.decode(encoded)
assert decoded.id == "eq1"
assert decoded.name == "Router 1"

View file

@ -4,7 +4,7 @@ defmodule Towerops.Snmp.DiscoveryTest do
import Mox
import Towerops.AccountsFixtures
alias Towerops.Equipment
alias Towerops.Devices.Device
alias Towerops.Snmp.Device
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Interface
@ -28,20 +28,20 @@ defmodule Towerops.Snmp.DiscoveryTest do
describe "discover_equipment/1" do
test "returns error when SNMP is not enabled", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: false
})
assert {:error, :snmp_not_enabled} = Discovery.discover_equipment(equipment)
assert {:error, :snmp_not_enabled} = Discovery.discover_equipment(device)
end
test "successfully discovers MikroTik device", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "MikroTik Router",
ip_address: "192.168.1.1",
site_id: site.id,
@ -147,7 +147,7 @@ defmodule Towerops.Snmp.DiscoveryTest do
end
end)
assert {:ok, device} = Discovery.discover_equipment(equipment)
assert {:ok, device} = Discovery.discover_equipment(device)
assert device.manufacturer == "MikroTik"
assert device.model == "RB750"
@ -164,8 +164,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
end
test "successfully discovers Cisco device", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Cisco Switch",
ip_address: "192.168.1.2",
site_id: site.id,
@ -212,7 +212,7 @@ defmodule Towerops.Snmp.DiscoveryTest do
{:ok, []}
end)
assert {:ok, device} = Discovery.discover_equipment(equipment)
assert {:ok, device} = Discovery.discover_equipment(device)
assert device.manufacturer == "Cisco"
assert device.model == "C2960"
@ -220,8 +220,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
end
test "handles connection failure", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Unreachable Device",
ip_address: "192.168.1.99",
site_id: site.id,
@ -236,12 +236,12 @@ defmodule Towerops.Snmp.DiscoveryTest do
{:error, :timeout}
end)
assert {:error, :timeout} = Discovery.discover_equipment(equipment)
assert {:error, :timeout} = Discovery.discover_equipment(device)
end
test "handles partial discovery failure gracefully", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Partial Device",
ip_address: "192.168.1.3",
site_id: site.id,
@ -292,7 +292,7 @@ defmodule Towerops.Snmp.DiscoveryTest do
end)
# Should succeed even with failed interface/sensor discovery
assert {:ok, device} = Discovery.discover_equipment(equipment)
assert {:ok, device} = Discovery.discover_equipment(device)
assert device.manufacturer == "Unknown"
@ -305,8 +305,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
end
test "updates existing device on re-discovery", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Existing Device",
ip_address: "192.168.1.4",
site_id: site.id,
@ -320,7 +320,7 @@ defmodule Towerops.Snmp.DiscoveryTest do
{:ok, initial_device} =
%Device{}
|> Device.changeset(%{
equipment_id: equipment.id,
device_id: device.id,
manufacturer: "Old Manufacturer",
model: "Old Model",
sys_name: "old-name"
@ -356,7 +356,7 @@ defmodule Towerops.Snmp.DiscoveryTest do
{:ok, []}
end)
assert {:ok, updated_device} = Discovery.discover_equipment(equipment)
assert {:ok, updated_device} = Discovery.discover_equipment(device)
# Should be same device, updated
assert updated_device.id == initial_device.id
@ -368,9 +368,9 @@ defmodule Towerops.Snmp.DiscoveryTest do
describe "discover_all/1" do
test "discovers multiple devices concurrently", %{organization: organization, site: site} do
# Create multiple SNMP-enabled equipment
{:ok, equipment1} =
Equipment.create_equipment(%{
# Create multiple SNMP-enabled devices
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.10",
site_id: site.id,
@ -380,8 +380,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
snmp_port: 161
})
{:ok, equipment2} =
Equipment.create_equipment(%{
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.1.11",
site_id: site.id,
@ -391,8 +391,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
snmp_port: 161
})
{:ok, _equipment3} =
Equipment.create_equipment(%{
{:ok, _device3} =
Towerops.Devices.create_device(%{
name: "Device 3 - No SNMP",
ip_address: "192.168.1.12",
site_id: site.id,
@ -426,13 +426,13 @@ defmodule Towerops.Snmp.DiscoveryTest do
assert summary.failed == 0
# Verify devices were created
assert Repo.get_by(Device, equipment_id: equipment1.id)
assert Repo.get_by(Device, equipment_id: equipment2.id)
assert Repo.get_by(Device, device_id: device1.id)
assert Repo.get_by(Device, device_id: device2.id)
end
test "handles mixed success and failure", %{organization: organization, site: site} do
{:ok, equipment1} =
Equipment.create_equipment(%{
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Good Device",
ip_address: "192.168.1.20",
site_id: site.id,
@ -442,8 +442,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
snmp_port: 161
})
{:ok, _equipment2} =
Equipment.create_equipment(%{
{:ok, _device2} =
Towerops.Devices.create_device(%{
name: "Bad Device",
ip_address: "192.168.1.21",
site_id: site.id,
@ -483,7 +483,7 @@ defmodule Towerops.Snmp.DiscoveryTest do
assert :auth_failure in summary.errors
# Verify only good device was created
assert Repo.get_by(Device, equipment_id: equipment1.id)
assert Repo.get_by(Device, device_id: device1.id)
end
end
end

View file

@ -19,8 +19,8 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
organization_id: organization.id
})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
{:ok, device_schema1} =
Towerops.Devices.create_device(%{
name: "Test Switch 1",
ip_address: "192.168.1.1",
snmp_enabled: true,
@ -30,8 +30,8 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
site_id: site.id
})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
{:ok, device_schema2} =
Towerops.Devices.create_device(%{
name: "Test Switch 2",
ip_address: "192.168.1.2",
snmp_enabled: true,
@ -44,7 +44,7 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
device1 =
%Device{}
|> Device.changeset(%{
equipment_id: equipment1.id,
device_id: device_schema1.id,
sys_name: "test-switch-1",
sys_descr: "Test Switch 1"
})
@ -53,7 +53,7 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
device2 =
%Device{}
|> Device.changeset(%{
equipment_id: equipment2.id,
device_id: device_schema2.id,
sys_name: "test-switch-2",
sys_descr: "Test Switch 2"
})
@ -84,8 +84,10 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
|> Repo.insert!()
%{
equipment1: equipment1,
equipment2: equipment2,
device_schema1: device_schema1,
device_schema2: device_schema2,
device1: device1,
device2: device2,
interface1: interface1,
interface2: interface2
}
@ -98,20 +100,22 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
GenServer.stop(pid)
end
test "cleanup removes stale neighbors across all equipment", %{
equipment1: equipment1,
equipment2: equipment2,
test "cleanup removes stale neighbors across all devices", %{
device_schema1: device_schema1,
device_schema2: device_schema2,
device1: device1,
device2: device2,
interface1: interface1,
interface2: interface2
} do
# Create old neighbors on both equipment
# Create old neighbors on both devices
two_days_ago = DateTime.add(DateTime.utc_now(), -48, :hour)
one_hour_ago = DateTime.add(DateTime.utc_now(), -1, :hour)
# Old neighbor on equipment1
# Old neighbor on device1
{:ok, old_neighbor1} =
Snmp.upsert_neighbor(%{
equipment_id: equipment1.id,
device_id: device_schema1.id,
interface_id: interface1.id,
protocol: "lldp",
remote_chassis_id: "old-neighbor-1",
@ -119,10 +123,10 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
last_discovered_at: two_days_ago
})
# Old neighbor on equipment2
# Old neighbor on device2
{:ok, old_neighbor2} =
Snmp.upsert_neighbor(%{
equipment_id: equipment2.id,
device_id: device_schema2.id,
interface_id: interface2.id,
protocol: "cdp",
remote_chassis_id: "old-neighbor-2",
@ -130,10 +134,10 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
last_discovered_at: two_days_ago
})
# Recent neighbor on equipment1
# Recent neighbor on device1
{:ok, recent_neighbor} =
Snmp.upsert_neighbor(%{
equipment_id: equipment1.id,
device_id: device_schema1.id,
interface_id: interface1.id,
protocol: "cdp",
remote_chassis_id: "recent-neighbor",
@ -158,9 +162,9 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
GenServer.stop(pid)
end
test "cleanup handles equipment with no neighbors gracefully", %{equipment1: equipment1} do
test "cleanup handles device with no neighbors gracefully", %{device1: device1} do
# Ensure no neighbors exist
assert Snmp.list_neighbors(equipment1.id) == []
assert Snmp.list_neighbors(device1.id) == []
{:ok, pid} = NeighborCleanupWorker.start_link()
send(pid, :cleanup)
@ -173,7 +177,8 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
end
test "cleanup preserves neighbors within 24 hour threshold", %{
equipment1: equipment1,
device_schema1: device_schema1,
device1: device1,
interface1: interface1
} do
# Create neighbors at various ages
@ -184,7 +189,7 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
# Neighbor just within threshold
{:ok, recent_neighbor} =
Snmp.upsert_neighbor(%{
equipment_id: equipment1.id,
device_id: device_schema1.id,
interface_id: interface1.id,
protocol: "lldp",
remote_chassis_id: "recent",
@ -195,7 +200,7 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
# Neighbor just outside threshold
{:ok, stale_neighbor} =
Snmp.upsert_neighbor(%{
equipment_id: equipment1.id,
device_id: device_schema1.id,
interface_id: interface1.id,
protocol: "cdp",
remote_chassis_id: "stale",
@ -217,7 +222,7 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
GenServer.stop(pid)
end
test "cleanup does not affect equipment without SNMP enabled" do
test "cleanup does not affect device without SNMP enabled" do
user = user_fixture()
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id)
@ -227,10 +232,10 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
organization_id: org.id
})
# Create equipment without SNMP enabled
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
name: "No SNMP Equipment",
# Create device without SNMP enabled
{:ok, device_schema} =
Towerops.Devices.create_device(%{
name: "No SNMP Device",
ip_address: "192.168.1.100",
snmp_enabled: false,
site_id: site.id
@ -239,7 +244,7 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
device =
%Device{}
|> Device.changeset(%{
equipment_id: equipment.id,
device_id: device_schema.id,
sys_name: "no-snmp-device",
sys_descr: "No SNMP Device"
})
@ -260,7 +265,7 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
{:ok, neighbor} =
Snmp.upsert_neighbor(%{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "neighbor",
@ -273,8 +278,8 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
Process.sleep(100)
# Neighbor should still exist (equipment not included in cleanup)
# because cleanup only processes SNMP-enabled equipment
# Neighbor should still exist (device not included in cleanup)
# because cleanup only processes SNMP-enabled devices
assert Repo.get(Neighbor, neighbor.id)
GenServer.stop(pid)

View file

@ -23,8 +23,8 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device_schema} =
Towerops.Devices.create_device(%{
name: "Test Switch",
ip_address: "192.168.1.1",
snmp_enabled: true,
@ -37,7 +37,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
device =
%Device{}
|> Device.changeset(%{
equipment_id: equipment.id,
device_id: device_schema.id,
sys_name: "test-switch",
sys_descr: "Test Switch"
})
@ -68,7 +68,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
|> Repo.insert!()
%{
equipment: equipment,
device_schema: device_schema,
device: device,
interface1: interface1,
interface2: interface2,
@ -77,7 +77,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end
describe "discover_neighbors/2" do
test "discovers LLDP neighbors", %{equipment: equipment, interface1: interface1} do
test "discovers LLDP neighbors", %{device_schema: device_schema, device: device, interface1: interface1} do
# Mock LLDP walk response
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
@ -101,13 +101,13 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end)
client_opts = [
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.snmp_port
ip: device_schema.ip_address,
community: device_schema.snmp_community,
version: device_schema.snmp_version,
port: device_schema.snmp_port
]
interfaces = [Map.put(interface1, :equipment_id, equipment.id)]
interfaces = [Map.put(interface1, :device_id, device_schema.id)]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
@ -116,14 +116,14 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
assert neighbor.protocol == "lldp"
assert neighbor.interface_id == interface1.id
assert neighbor.equipment_id == equipment.id
assert neighbor.device_id == device_schema.id
assert neighbor.remote_system_name == "neighbor-switch"
assert neighbor.remote_port_id == "Gi0/1"
assert neighbor.remote_port_description == "GigabitEthernet0/1"
assert neighbor.remote_system_description == "Cisco IOS Software"
end
test "discovers CDP neighbors", %{equipment: equipment, interface2: interface2} do
test "discovers CDP neighbors", %{device_schema: device_schema, device: device, interface2: interface2} do
# Mock LLDP walk response (empty)
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok, []}
@ -145,13 +145,13 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end)
client_opts = [
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.snmp_port
ip: device_schema.ip_address,
community: device_schema.snmp_community,
version: device_schema.snmp_version,
port: device_schema.snmp_port
]
interfaces = [Map.put(interface2, :equipment_id, equipment.id)]
interfaces = [Map.put(interface2, :device_id, device_schema.id)]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
@ -160,7 +160,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
assert neighbor.protocol == "cdp"
assert neighbor.interface_id == interface2.id
assert neighbor.equipment_id == equipment.id
assert neighbor.device_id == device_schema.id
assert neighbor.remote_system_name == "neighbor-router.example.com"
assert neighbor.remote_port_id == "FastEthernet0/1"
assert neighbor.remote_platform == "cisco ISR4331/K9"
@ -168,7 +168,8 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end
test "discovers both LLDP and CDP neighbors", %{
equipment: equipment,
device_schema: device_schema,
device: device,
interface1: interface1,
interface2: interface2
} do
@ -191,15 +192,15 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end)
client_opts = [
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.snmp_port
ip: device_schema.ip_address,
community: device_schema.snmp_community,
version: device_schema.snmp_version,
port: device_schema.snmp_port
]
interfaces = [
Map.put(interface1, :equipment_id, equipment.id),
Map.put(interface2, :equipment_id, equipment.id)
Map.put(interface1, :device_id, device_schema.id),
Map.put(interface2, :device_id, device_schema.id)
]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
@ -210,7 +211,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
assert "cdp" in protocols
end
test "returns empty list when no neighbors found", %{equipment: equipment} do
test "returns empty list when no neighbors found", %{device_schema: device_schema, device: device} do
# Mock LLDP walk response (empty)
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok, []}
@ -222,10 +223,10 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end)
client_opts = [
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.snmp_port
ip: device_schema.ip_address,
community: device_schema.snmp_community,
version: device_schema.snmp_version,
port: device_schema.snmp_port
]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, [])
@ -233,7 +234,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
assert neighbors == []
end
test "handles SNMP errors gracefully", %{equipment: equipment, interface1: interface1} do
test "handles SNMP errors gracefully", %{device_schema: device_schema, device: device, interface1: interface1} do
# Mock LLDP walk error
expect(SnmpMock, :walk, fn _, _, _ ->
{:error, :timeout}
@ -245,13 +246,13 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end)
client_opts = [
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.snmp_port
ip: device_schema.ip_address,
community: device_schema.snmp_community,
version: device_schema.snmp_version,
port: device_schema.snmp_port
]
interfaces = [Map.put(interface1, :equipment_id, equipment.id)]
interfaces = [Map.put(interface1, :device_id, device_schema.id)]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
@ -261,9 +262,9 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end
describe "upsert_neighbor/1" do
test "creates a new neighbor record", %{equipment: equipment, interface1: interface1} do
test "creates a new neighbor record", %{device_schema: device_schema, device: device, interface1: interface1} do
attrs = %{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface1.id,
protocol: "lldp",
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
@ -284,9 +285,9 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
assert neighbor.remote_capabilities == ["router", "bridge"]
end
test "updates existing neighbor record", %{equipment: equipment, interface1: interface1} do
test "updates existing neighbor record", %{device_schema: device_schema, device: device, interface1: interface1} do
attrs = %{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface1.id,
protocol: "cdp",
remote_chassis_id: "neighbor-switch",
@ -299,7 +300,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
# Update with new data
updated_attrs = %{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface1.id,
protocol: "cdp",
remote_chassis_id: "neighbor-switch",
@ -317,12 +318,13 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end
test "creates separate records for different protocols", %{
equipment: equipment,
device_schema: device_schema,
device: device,
interface1: interface1
} do
# Create LLDP neighbor
lldp_attrs = %{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface1.id,
protocol: "lldp",
remote_chassis_id: "same-chassis-id",
@ -332,7 +334,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
# Create CDP neighbor with same chassis ID
cdp_attrs = %{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface1.id,
protocol: "cdp",
remote_chassis_id: "same-chassis-id",
@ -352,14 +354,15 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
describe "list_neighbors/1" do
test "returns all neighbors for equipment", %{
equipment: equipment,
device_schema: device_schema,
device: device,
interface1: interface1,
interface2: interface2
} do
# Create neighbors on different interfaces
{:ok, _neighbor1} =
Snmp.upsert_neighbor(%{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface1.id,
protocol: "lldp",
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
@ -369,7 +372,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
{:ok, _neighbor2} =
Snmp.upsert_neighbor(%{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface2.id,
protocol: "cdp",
remote_chassis_id: "neighbor2",
@ -377,27 +380,28 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
last_discovered_at: DateTime.utc_now()
})
neighbors = Snmp.list_neighbors(equipment.id)
neighbors = Snmp.list_neighbors(device_schema.id)
assert length(neighbors) == 2
assert Enum.all?(neighbors, &(&1.equipment_id == equipment.id))
assert Enum.all?(neighbors, &(&1.device_id == device_schema.id))
# Check that interface is preloaded
assert Enum.all?(neighbors, &Ecto.assoc_loaded?(&1.interface))
end
test "returns empty list for equipment with no neighbors", %{equipment: equipment} do
neighbors = Snmp.list_neighbors(equipment.id)
test "returns empty list for equipment with no neighbors", %{device_schema: device_schema, device: device} do
neighbors = Snmp.list_neighbors(device_schema.id)
assert neighbors == []
end
test "orders neighbors by protocol and system name", %{
equipment: equipment,
device_schema: device_schema,
device: device,
interface1: interface1
} do
# Create neighbors in reverse alphabetical order
{:ok, _} =
Snmp.upsert_neighbor(%{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface1.id,
protocol: "lldp",
remote_chassis_id: "chassis-z",
@ -407,7 +411,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
{:ok, _} =
Snmp.upsert_neighbor(%{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface1.id,
protocol: "cdp",
remote_chassis_id: "chassis-a",
@ -415,7 +419,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
last_discovered_at: DateTime.utc_now()
})
neighbors = Snmp.list_neighbors(equipment.id)
neighbors = Snmp.list_neighbors(device_schema.id)
# Should be ordered by protocol, then system name
assert length(neighbors) == 2
@ -426,13 +430,17 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end
describe "delete_stale_neighbors/2" do
test "deletes neighbors not seen since cutoff", %{equipment: equipment, interface1: interface1} do
test "deletes neighbors not seen since cutoff", %{
device_schema: device_schema,
device: device,
interface1: interface1
} do
# Create old neighbor
two_days_ago = DateTime.add(DateTime.utc_now(), -48, :hour)
{:ok, old_neighbor} =
Snmp.upsert_neighbor(%{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface1.id,
protocol: "lldp",
remote_chassis_id: "old-neighbor",
@ -443,7 +451,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
# Create recent neighbor
{:ok, recent_neighbor} =
Snmp.upsert_neighbor(%{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface1.id,
protocol: "cdp",
remote_chassis_id: "recent-neighbor",
@ -453,7 +461,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
# Delete neighbors older than 24 hours
cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
{count, _} = Snmp.delete_stale_neighbors(equipment.id, cutoff)
{count, _} = Snmp.delete_stale_neighbors(device_schema.id, cutoff)
assert count == 1
@ -465,15 +473,16 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end
test "only deletes neighbors for specified equipment", %{
device_schema: device_schema,
interface1: interface1,
equipment: equipment
device: device
} do
user = user_fixture()
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user.id)
{:ok, site} = Towerops.Sites.create_site(%{name: "Site 2", organization_id: org.id})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
Towerops.Devices.create_device(%{
name: "Other Equipment",
ip_address: "192.168.1.2",
site_id: site.id
@ -482,7 +491,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
device2 =
%Device{}
|> Device.changeset(%{
equipment_id: equipment2.id,
device_id: equipment2.id,
sys_name: "other-device",
sys_descr: "Other Device"
})
@ -503,7 +512,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
# Create old neighbors on both equipment
{:ok, neighbor1} =
Snmp.upsert_neighbor(%{
equipment_id: equipment.id,
device_id: device_schema.id,
interface_id: interface1.id,
protocol: "lldp",
remote_chassis_id: "neighbor1",
@ -513,7 +522,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
{:ok, neighbor2} =
Snmp.upsert_neighbor(%{
equipment_id: equipment2.id,
device_id: equipment2.id,
interface_id: interface2.id,
protocol: "lldp",
remote_chassis_id: "neighbor2",
@ -523,7 +532,7 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
# Delete only from first equipment
cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
{count, _} = Snmp.delete_stale_neighbors(equipment.id, cutoff)
{count, _} = Snmp.delete_stale_neighbors(device_schema.id, cutoff)
assert count == 1
@ -534,9 +543,9 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
assert Repo.get(Neighbor, neighbor2.id)
end
test "returns count of 0 when no stale neighbors", %{equipment: equipment} do
test "returns count of 0 when no stale neighbors", %{device_schema: device_schema, device: device} do
cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
{count, _} = Snmp.delete_stale_neighbors(equipment.id, cutoff)
{count, _} = Snmp.delete_stale_neighbors(device_schema.id, cutoff)
assert count == 0
end

View file

@ -97,15 +97,15 @@ defmodule Towerops.Snmp.PollerTest do
end
describe "build_client_opts/1" do
test "builds client options from equipment map" do
equipment = %{
test "builds client options from device map" do
device = %{
ip_address: "192.168.1.100",
snmp_community: "private",
snmp_version: "2c",
snmp_port: 161
}
client_opts = Poller.build_client_opts(equipment)
client_opts = Poller.build_client_opts(device)
assert client_opts[:ip] == "192.168.1.100"
assert client_opts[:community] == "private"
@ -115,53 +115,53 @@ defmodule Towerops.Snmp.PollerTest do
end
test "uses default port 161 when snmp_port is nil" do
equipment = %{
device = %{
ip_address: "192.168.1.100",
snmp_community: "public",
snmp_version: "2c",
snmp_port: nil
}
client_opts = Poller.build_client_opts(equipment)
client_opts = Poller.build_client_opts(device)
assert client_opts[:port] == 161
end
test "handles custom SNMP port" do
equipment = %{
device = %{
ip_address: "192.168.1.100",
snmp_community: "public",
snmp_version: "2c",
snmp_port: 1161
}
client_opts = Poller.build_client_opts(equipment)
client_opts = Poller.build_client_opts(device)
assert client_opts[:port] == 1161
end
test "builds options for SNMPv1" do
equipment = %{
device = %{
ip_address: "10.0.0.1",
snmp_community: "public",
snmp_version: "1",
snmp_port: 161
}
client_opts = Poller.build_client_opts(equipment)
client_opts = Poller.build_client_opts(device)
assert client_opts[:version] == "1"
end
test "includes standard timeout of 5 seconds" do
equipment = %{
device = %{
ip_address: "192.168.1.1",
snmp_community: "public",
snmp_version: "2c",
snmp_port: 161
}
client_opts = Poller.build_client_opts(equipment)
client_opts = Poller.build_client_opts(device)
assert client_opts[:timeout] == 5000
end

View file

@ -21,8 +21,8 @@ defmodule Towerops.SnmpTest do
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
snmp_enabled: true,
@ -32,16 +32,16 @@ defmodule Towerops.SnmpTest do
site_id: site.id
})
device =
snmp_device =
%Device{}
|> Device.changeset(%{
equipment_id: equipment.id,
device_id: device.id,
sys_name: "test-router",
sys_descr: "Test Device"
})
|> Repo.insert!()
%{equipment: equipment, device: device, organization: organization}
%{device: device, snmp_device: snmp_device, organization: organization}
end
describe "test_connection/4" do
@ -77,36 +77,36 @@ defmodule Towerops.SnmpTest do
end
describe "get_device/1" do
test "returns device for equipment_id", %{device: device, equipment: equipment} do
found_device = Snmp.get_device(equipment.id)
assert found_device.id == device.id
test "returns device for device_id", %{device: device, snmp_device: snmp_device} do
found_device = Snmp.get_device(device.id)
assert found_device.id == snmp_device.id
assert found_device.sys_name == "test-router"
end
test "returns nil for non-existent equipment_id" do
test "returns nil for non-existent device_id" do
assert Snmp.get_device(Ecto.UUID.generate()) == nil
end
end
describe "get_device_with_associations/1" do
test "returns device with preloaded associations", %{device: device, equipment: equipment} do
found_device = Snmp.get_device_with_associations(equipment.id)
assert found_device.id == device.id
test "returns device with preloaded associations", %{device: device, snmp_device: snmp_device} do
found_device = Snmp.get_device_with_associations(device.id)
assert found_device.id == snmp_device.id
assert Ecto.assoc_loaded?(found_device.interfaces)
assert Ecto.assoc_loaded?(found_device.sensors)
end
test "returns nil for non-existent equipment_id" do
test "returns nil for non-existent device_id" do
assert Snmp.get_device_with_associations(Ecto.UUID.generate()) == nil
end
end
describe "list_interfaces/1" do
test "returns all interfaces for a device ordered by if_index", %{device: device} do
test "returns all interfaces for a device ordered by if_index", %{device: device, snmp_device: snmp_device} do
interface1 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1"
})
@ -115,29 +115,29 @@ defmodule Towerops.SnmpTest do
interface2 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
interfaces = Snmp.list_interfaces(device.id)
interfaces = Snmp.list_interfaces(snmp_device.id)
assert length(interfaces) == 2
assert hd(interfaces).id == interface2.id
assert List.last(interfaces).id == interface1.id
end
test "returns empty list for device with no interfaces", %{device: device} do
assert Snmp.list_interfaces(device.id) == []
test "returns empty list for device with no interfaces", %{device: device, snmp_device: snmp_device} do
assert Snmp.list_interfaces(snmp_device.id) == []
end
end
describe "list_monitored_interfaces/1" do
test "returns only monitored interfaces", %{device: device} do
test "returns only monitored interfaces", %{device: device, snmp_device: snmp_device} do
monitored =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
monitored: true
@ -147,25 +147,25 @@ defmodule Towerops.SnmpTest do
_unmonitored =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1",
monitored: false
})
|> Repo.insert!()
interfaces = Snmp.list_monitored_interfaces(device.id)
interfaces = Snmp.list_monitored_interfaces(snmp_device.id)
assert length(interfaces) == 1
assert hd(interfaces).id == monitored.id
end
end
describe "get_interface/1" do
test "returns interface by id", %{device: device} do
test "returns interface by id", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
@ -182,11 +182,11 @@ defmodule Towerops.SnmpTest do
end
describe "update_interface/2" do
test "updates interface attributes", %{device: device} do
test "updates interface attributes", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
monitored: false
@ -197,11 +197,11 @@ defmodule Towerops.SnmpTest do
assert updated.monitored == true
end
test "returns error for invalid attributes", %{device: device} do
test "returns error for invalid attributes", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
@ -213,11 +213,11 @@ defmodule Towerops.SnmpTest do
end
describe "list_sensors/1" do
test "returns all sensors for a device ordered by type and index", %{device: device} do
test "returns all sensors for a device ordered by type and index", %{device: device, snmp_device: snmp_device} do
sensor1 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
@ -227,30 +227,30 @@ defmodule Towerops.SnmpTest do
sensor2 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "2",
sensor_oid: "1.2.3.5"
})
|> Repo.insert!()
sensors = Snmp.list_sensors(device.id)
sensors = Snmp.list_sensors(snmp_device.id)
assert length(sensors) == 2
assert sensor1.id in Enum.map(sensors, & &1.id)
assert sensor2.id in Enum.map(sensors, & &1.id)
end
test "returns empty list for device with no sensors", %{device: device} do
assert Snmp.list_sensors(device.id) == []
test "returns empty list for device with no sensors", %{device: device, snmp_device: snmp_device} do
assert Snmp.list_sensors(snmp_device.id) == []
end
end
describe "list_monitored_sensors/1" do
test "returns only monitored sensors", %{device: device} do
test "returns only monitored sensors", %{device: device, snmp_device: snmp_device} do
monitored =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4",
@ -261,7 +261,7 @@ defmodule Towerops.SnmpTest do
_unmonitored =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "2",
sensor_oid: "1.2.3.5",
@ -269,18 +269,18 @@ defmodule Towerops.SnmpTest do
})
|> Repo.insert!()
sensors = Snmp.list_monitored_sensors(device.id)
sensors = Snmp.list_monitored_sensors(snmp_device.id)
assert length(sensors) == 1
assert hd(sensors).id == monitored.id
end
end
describe "list_sensors_by_type/1" do
test "groups sensors by type", %{device: device} do
test "groups sensors by type", %{device: device, snmp_device: snmp_device} do
_temp1 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
@ -290,7 +290,7 @@ defmodule Towerops.SnmpTest do
_temp2 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "2",
sensor_oid: "1.2.3.5"
@ -300,31 +300,31 @@ defmodule Towerops.SnmpTest do
_power =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "3",
sensor_oid: "1.2.3.6"
})
|> Repo.insert!()
grouped = Snmp.list_sensors_by_type(device.id)
grouped = Snmp.list_sensors_by_type(snmp_device.id)
assert Map.has_key?(grouped, "temperature")
assert Map.has_key?(grouped, "power")
assert length(grouped["temperature"]) == 2
assert length(grouped["power"]) == 1
end
test "returns empty map for device with no sensors", %{device: device} do
assert Snmp.list_sensors_by_type(device.id) == %{}
test "returns empty map for device with no sensors", %{device: device, snmp_device: snmp_device} do
assert Snmp.list_sensors_by_type(snmp_device.id) == %{}
end
end
describe "get_sensor/1" do
test "returns sensor by id", %{device: device} do
test "returns sensor by id", %{device: device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
@ -342,11 +342,11 @@ defmodule Towerops.SnmpTest do
end
describe "update_sensor/2" do
test "updates sensor attributes", %{device: device} do
test "updates sensor attributes", %{device: device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4",
@ -358,11 +358,11 @@ defmodule Towerops.SnmpTest do
assert updated.monitored == true
end
test "returns error for invalid attributes", %{device: device} do
test "returns error for invalid attributes", %{device: device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
@ -375,11 +375,11 @@ defmodule Towerops.SnmpTest do
end
describe "get_sensor_readings/2" do
test "returns recent readings for a sensor", %{device: device} do
test "returns recent readings for a sensor", %{device: device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
@ -401,11 +401,11 @@ defmodule Towerops.SnmpTest do
assert length(readings) == 5
end
test "respects limit option", %{device: device} do
test "respects limit option", %{device: device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
@ -427,11 +427,11 @@ defmodule Towerops.SnmpTest do
assert length(readings) == 3
end
test "respects since option", %{device: device} do
test "respects since option", %{device: device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
@ -465,11 +465,11 @@ defmodule Towerops.SnmpTest do
assert hd(readings).value == 25.0
end
test "returns empty list for sensor with no readings", %{device: device} do
test "returns empty list for sensor with no readings", %{device: device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
@ -481,11 +481,11 @@ defmodule Towerops.SnmpTest do
end
describe "get_latest_sensor_reading/1" do
test "returns most recent reading", %{device: device} do
test "returns most recent reading", %{device: device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
@ -517,11 +517,11 @@ defmodule Towerops.SnmpTest do
refute latest.id == old_reading.id
end
test "returns nil for sensor with no readings", %{device: device} do
test "returns nil for sensor with no readings", %{device: device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
@ -533,11 +533,11 @@ defmodule Towerops.SnmpTest do
end
describe "get_interface_stats/2" do
test "returns recent stats for an interface", %{device: device} do
test "returns recent stats for an interface", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
@ -558,11 +558,11 @@ defmodule Towerops.SnmpTest do
assert length(stats) == 5
end
test "respects limit option", %{device: device} do
test "respects limit option", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
@ -583,11 +583,11 @@ defmodule Towerops.SnmpTest do
assert length(stats) == 3
end
test "respects since option", %{device: device} do
test "respects since option", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
@ -620,11 +620,11 @@ defmodule Towerops.SnmpTest do
assert hd(stats).if_in_octets == 5000
end
test "returns empty list for interface with no stats", %{device: device} do
test "returns empty list for interface with no stats", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
@ -635,11 +635,11 @@ defmodule Towerops.SnmpTest do
end
describe "get_latest_interface_stat/1" do
test "returns most recent stat", %{device: device} do
test "returns most recent stat", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
@ -670,11 +670,11 @@ defmodule Towerops.SnmpTest do
refute latest.id == old_stat.id
end
test "returns nil for interface with no stats", %{device: device} do
test "returns nil for interface with no stats", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
@ -685,11 +685,11 @@ defmodule Towerops.SnmpTest do
end
describe "create_sensor_reading/1" do
test "creates a sensor reading with valid attributes", %{device: device} do
test "creates a sensor reading with valid attributes", %{device: device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
@ -716,11 +716,11 @@ defmodule Towerops.SnmpTest do
end
describe "create_interface_stat/1" do
test "creates an interface stat with valid attributes", %{device: device} do
test "creates an interface stat with valid attributes", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})

View file

@ -14,14 +14,14 @@ defmodule ToweropsWeb.AlertLive.IndexTest do
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
site_id: site.id
})
%{organization: organization, site: site, equipment: equipment}
%{organization: organization, site: site, device: device}
end
describe "Index" do
@ -38,57 +38,57 @@ defmodule ToweropsWeb.AlertLive.IndexTest do
assert html =~ "No alerts"
end
test "lists all alerts", %{conn: conn, organization: organization, equipment: equipment} do
test "lists all alerts", %{conn: conn, organization: organization, device: device} do
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Equipment is down"
message: "Device is down"
})
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/alerts")
assert html =~ "Test Router"
assert html =~ "Equipment is down"
assert html =~ "Equipment Down"
assert html =~ "Device is down"
assert html =~ "Device Down"
end
test "filters active alerts", %{conn: conn, organization: organization, equipment: equipment} do
test "filters active alerts", %{conn: conn, organization: organization, device: device} do
# Create an active equipment_down alert
{:ok, _active_alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Equipment is down"
message: "Device is down"
})
# Create a recovery alert (equipment_up)
# Create a recovery alert (device_up)
{:ok, _recovery_alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_up,
device_id: device.id,
alert_type: :device_up,
triggered_at: DateTime.utc_now(),
message: "Equipment recovered"
message: "Device recovered"
})
{:ok, _view, html} =
live(conn, ~p"/orgs/#{organization.slug}/alerts?filter=active")
# Should show equipment_down alert
assert html =~ "Equipment is down"
assert html =~ "Device is down"
# Should NOT show equipment_up alert in active filter
refute html =~ "Equipment recovered"
refute html =~ "Device recovered"
end
test "acknowledges an alert", %{conn: conn, organization: organization, equipment: equipment} do
test "acknowledges an alert", %{conn: conn, organization: organization, device: device} do
{:ok, alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Equipment is down"
message: "Device is down"
})
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/alerts")
@ -101,36 +101,36 @@ defmodule ToweropsWeb.AlertLive.IndexTest do
assert html =~ "Alert acknowledged"
end
test "does not show acknowledge button for equipment_up alerts", %{
test "does not show acknowledge button for device_up alerts", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_up,
device_id: device.id,
alert_type: :device_up,
triggered_at: DateTime.utc_now(),
message: "Equipment recovered"
message: "Device recovered"
})
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/alerts")
# Should not show acknowledge button for equipment_up alerts
# Should not show acknowledge button for device_up alerts
refute html =~ "Acknowledge"
end
test "does not show acknowledge button for resolved alerts", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Equipment is down"
message: "Device is down"
})
# Resolve the alert
@ -145,7 +145,7 @@ defmodule ToweropsWeb.AlertLive.IndexTest do
test "updates in real-time when new alert is created", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/alerts")
@ -153,7 +153,7 @@ defmodule ToweropsWeb.AlertLive.IndexTest do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:new",
{:new_alert, equipment.id, :equipment_down}
{:new_alert, device.id, :device_down}
)
# View should update
@ -163,14 +163,14 @@ defmodule ToweropsWeb.AlertLive.IndexTest do
test "updates in real-time when alert is resolved", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Equipment is down"
message: "Device is down"
})
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/alerts")
@ -181,7 +181,7 @@ defmodule ToweropsWeb.AlertLive.IndexTest do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:resolved",
{:alert_resolved, equipment.id, :equipment_down}
{:alert_resolved, device.id, :device_down}
)
# View should update

View file

@ -23,7 +23,7 @@ defmodule ToweropsWeb.DashboardLiveTest do
assert html =~ organization.name
assert html =~ "Sites"
assert html =~ "Equipment"
assert html =~ "Device"
assert html =~ "Active Alerts"
end
@ -39,37 +39,37 @@ defmodule ToweropsWeb.DashboardLiveTest do
assert html =~ "Sites"
end
test "displays correct equipment counts", %{conn: conn, organization: organization, site: site} do
# Create equipment with different statuses
{:ok, equipment_up} =
Towerops.Equipment.create_equipment(%{
test "displays correct device counts", %{conn: conn, organization: organization, site: site} do
# Create devices with different statuses
{:ok, device_up} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
monitoring_enabled: true
})
Towerops.Equipment.update_equipment_status(equipment_up, :up)
Towerops.Devices.update_device_status(device_up, :up)
{:ok, equipment_down} =
Towerops.Equipment.create_equipment(%{
{:ok, device_down} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
monitoring_enabled: true
})
Towerops.Equipment.update_equipment_status(equipment_down, :down)
Towerops.Devices.update_device_status(device_down, :down)
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}")
assert html =~ "Equipment"
assert html =~ "Device"
assert html =~ "Up"
assert html =~ "Down"
end
test "displays active alerts count", %{conn: conn, organization: organization, site: site} do
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
@ -78,10 +78,10 @@ defmodule ToweropsWeb.DashboardLiveTest do
# Create an active alert
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Equipment is down"
message: "Device is down"
})
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}")
@ -93,8 +93,8 @@ defmodule ToweropsWeb.DashboardLiveTest do
organization: organization,
site: site
} do
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
@ -106,7 +106,7 @@ defmodule ToweropsWeb.DashboardLiveTest do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:new",
{:new_alert, equipment.id, :equipment_down}
{:new_alert, device.id, :device_down}
)
# View should update
@ -118,7 +118,7 @@ defmodule ToweropsWeb.DashboardLiveTest do
# Check for navigation links
assert html =~ ~p"/orgs/#{organization.slug}/sites"
assert html =~ ~p"/orgs/#{organization.slug}/equipment"
assert html =~ ~p"/orgs/#{organization.slug}/devices"
assert html =~ ~p"/orgs/#{organization.slug}/alerts"
end

View file

@ -1,10 +1,9 @@
defmodule ToweropsWeb.EquipmentLive.FormTest do
defmodule ToweropsWeb.DeviceLive.FormTest do
use ToweropsWeb.ConnCase
import Mox
import Phoenix.LiveViewTest
alias Towerops.Equipment
alias Towerops.Snmp.SnmpMock
setup :register_and_log_in_user
@ -26,7 +25,7 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
%{organization: organization}
end
describe "New Equipment Form" do
describe "New Device Form" do
setup %{organization: organization} do
{:ok, site} =
Towerops.Sites.create_site(%{
@ -37,11 +36,11 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
%{site: site}
end
test "renders new equipment form", %{conn: conn, organization: organization} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
test "renders new device form", %{conn: conn, organization: organization} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
assert html =~ "New Equipment"
assert html =~ "Add new equipment to monitor"
assert html =~ "New Device"
assert html =~ "Add new device to monitor"
end
test "redirects to sites page when no sites exist", %{conn: conn, user: user} do
@ -50,25 +49,25 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
{:ok, _view, html} =
conn
|> live(~p"/orgs/#{empty_org.slug}/equipment/new")
|> live(~p"/orgs/#{empty_org.slug}/devices/new")
|> follow_redirect(conn, ~p"/orgs/#{empty_org.slug}/sites/new")
assert html =~ "Please create a site before adding equipment"
assert html =~ "Please create a site before adding a device"
end
test "validates form inputs", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
html =
view
|> form("#equipment-form", equipment: %{name: "", ip_address: ""})
|> form("#device-form", device: %{name: "", ip_address: ""})
|> render_change()
assert html =~ "equipment-form"
assert html =~ "device-form"
end
test "auto-selects single site", %{conn: conn, organization: organization, site: site} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
# The form should have the site pre-selected
assert html =~ site.name
@ -82,7 +81,7 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
})
{:ok, _view, html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/new?site_id=#{site2.id}")
live(conn, ~p"/orgs/#{organization.slug}/devices/new?site_id=#{site2.id}")
# Should use the query param site
assert html =~ site2.name
@ -99,7 +98,7 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
organization_id: organization.id
})
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
# Should show both sites in the form
assert html =~ "Test Site"
@ -107,7 +106,7 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
end
end
describe "Edit Equipment Form" do
describe "Edit Device Form" do
setup %{organization: organization} do
{:ok, site} =
Towerops.Sites.create_site(%{
@ -115,52 +114,52 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
organization_id: organization.id
})
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
})
%{site: site, equipment: equipment}
%{site: site, device: device}
end
test "renders edit form", %{conn: conn, organization: organization, equipment: equipment} do
test "renders edit form", %{conn: conn, organization: organization, device: device} do
{:ok, _view, html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
assert html =~ "Edit Equipment"
assert html =~ equipment.name
assert html =~ "Edit Device"
assert html =~ device.name
end
test "validates edited form inputs", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
html =
view
|> form("#equipment-form", equipment: %{name: ""})
|> form("#device-form", device: %{name: ""})
|> render_change()
assert html =~ "equipment-form"
assert html =~ "device-form"
end
test "handles save error on edit", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
# Submit with invalid data
html =
view
|> form("#equipment-form", equipment: %{ip_address: "invalid-ip"})
|> form("#device-form", device: %{ip_address: "invalid-ip"})
|> render_submit()
assert html =~ "must be a valid IPv4 or IPv6 address"
@ -169,24 +168,24 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
test "loads current agent assignment if exists", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
# Create an agent token
{:ok, agent_token, _token} =
Towerops.Agents.create_agent_token(organization.id, "Test Agent")
# Assign the agent to the equipment
Towerops.Agents.update_equipment_assignment(equipment.id, agent_token.id)
# Assign the agent to the device
Towerops.Agents.update_device_assignment(device.id, agent_token.id)
{:ok, _view, html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
# Should show the form successfully
assert html =~ "Edit Equipment"
assert html =~ "Edit Device"
end
end
describe "Delete Equipment" do
describe "Delete Device" do
setup %{organization: organization} do
{:ok, site} =
Towerops.Sites.create_site(%{
@ -194,31 +193,31 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
organization_id: organization.id
})
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
})
%{site: site, equipment: equipment}
%{site: site, device: device}
end
test "deletes equipment successfully", %{
test "deletes device successfully", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
{:ok, _, html} =
view
|> element("button", "Delete Equipment")
|> element("button", "Delete Device")
|> render_click()
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment")
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/devices")
assert html =~ "Equipment deleted successfully"
assert html =~ "Device deleted successfully"
end
end
@ -234,7 +233,7 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
end
test "tests SNMP connection successfully", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
# Mock successful SNMP test
expect(SnmpMock, :get, fn _target, _oid, _opts ->
@ -243,8 +242,8 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
# Fill in form with valid data
view
|> form("#equipment-form",
equipment: %{
|> form("#device-form",
device: %{
ip_address: "192.168.1.1",
snmp_community: "public",
snmp_version: "2c",
@ -257,11 +256,11 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
html = render_click(view, "test_snmp")
# Should show success message in test result
assert html =~ "test-result" or html =~ "Equipment"
assert html =~ "test-result" or html =~ "Device"
end
test "tests SNMP connection with failure", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
# Mock failed SNMP test
expect(SnmpMock, :get, fn _target, _oid, _opts ->
@ -270,8 +269,8 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
# Fill in form
view
|> form("#equipment-form",
equipment: %{
|> form("#device-form",
device: %{
ip_address: "192.168.1.1",
snmp_community: "public",
snmp_version: "2c",
@ -288,7 +287,7 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
end
test "validates SNMP test requires IP address", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
# Test without IP
html = render_click(view, "test_snmp")
@ -301,11 +300,11 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
conn: conn,
organization: organization
} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
# Set IP but not community
view
|> form("#equipment-form", equipment: %{ip_address: "192.168.1.1"})
|> form("#device-form", device: %{ip_address: "192.168.1.1"})
|> render_change()
html = render_click(view, "test_snmp")
@ -318,12 +317,12 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
conn: conn,
organization: organization
} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
# Set IP with empty community
view
|> form("#equipment-form",
equipment: %{ip_address: "192.168.1.1", snmp_community: ""}
|> form("#device-form",
device: %{ip_address: "192.168.1.1", snmp_community: ""}
)
|> render_change()
@ -337,12 +336,12 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
conn: conn,
organization: organization
} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
# Set invalid IP
view
|> form("#equipment-form",
equipment: %{ip_address: "invalid", snmp_community: "public"}
|> form("#device-form",
device: %{ip_address: "invalid", snmp_community: "public"}
)
|> render_change()
@ -353,7 +352,7 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
end
test "normalizes port numbers in SNMP test", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
# Mock SNMP test
expect(SnmpMock, :get, fn _target, _oid, opts ->
@ -364,8 +363,8 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
# Set string port
view
|> form("#equipment-form",
equipment: %{
|> form("#device-form",
device: %{
ip_address: "192.168.1.1",
snmp_community: "public",
snmp_version: "2c",
@ -378,7 +377,7 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
end
test "handles invalid port in SNMP test", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
# Mock SNMP test
expect(SnmpMock, :get, fn _target, _oid, opts ->
@ -389,8 +388,8 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
# Set invalid port
view
|> form("#equipment-form",
equipment: %{
|> form("#device-form",
device: %{
ip_address: "192.168.1.1",
snmp_community: "public",
snmp_version: "2c",
@ -411,8 +410,8 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
organization_id: organization.id
})
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
@ -421,29 +420,29 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
snmp_version: "2c"
})
%{site: site, equipment: equipment}
%{site: site, device: device}
end
test "triggers discovery for SNMP-enabled equipment", %{
test "triggers discovery for SNMP-enabled devices", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
html = render_click(view, "trigger_discovery")
assert html =~ "Discovery started"
end
test "rejects discovery for non-SNMP equipment", %{
test "rejects discovery for non-SNMP device", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, non_snmp_equipment} =
Equipment.create_equipment(%{
{:ok, non_snmp_device} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
@ -451,7 +450,7 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
})
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{non_snmp_equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{non_snmp_device.id}/edit")
html = render_click(view, "trigger_discovery")
@ -467,8 +466,8 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
organization_id: organization.id
})
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
@ -478,7 +477,7 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
snmp_port: 161
})
%{site: site, equipment: equipment}
%{site: site, device: device}
end
test "triggers discovery when enabling SNMP", %{
@ -486,9 +485,9 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
organization: organization,
site: site
} do
# Create equipment without SNMP
{:ok, equipment} =
Equipment.create_equipment(%{
# Create device without SNMP
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
@ -496,25 +495,25 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
})
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
# First, toggle SNMP on via change event to reveal fields
view
|> form("#equipment-form", equipment: %{snmp_enabled: true})
|> form("#device-form", device: %{snmp_enabled: true})
|> render_change()
# Now submit with SNMP settings
{:ok, _, html} =
view
|> form("#equipment-form",
equipment: %{
|> form("#device-form",
device: %{
snmp_enabled: true,
snmp_community: "public",
snmp_version: "2c"
}
)
|> render_submit()
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
assert html =~ "SNMP discovery started in background"
end
@ -522,17 +521,17 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
test "triggers discovery when changing SNMP community", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
# Change community
{:ok, _, html} =
view
|> form("#equipment-form", equipment: %{snmp_community: "private"})
|> form("#device-form", device: %{snmp_community: "private"})
|> render_submit()
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
assert html =~ "SNMP discovery started in background"
end
@ -540,17 +539,17 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
test "triggers discovery when changing SNMP version", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
# Change version
{:ok, _, html} =
view
|> form("#equipment-form", equipment: %{snmp_version: "1"})
|> form("#device-form", device: %{snmp_version: "1"})
|> render_submit()
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
assert html =~ "SNMP discovery started in background"
end
@ -558,17 +557,17 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
test "triggers discovery when changing SNMP port", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
# Change port
{:ok, _, html} =
view
|> form("#equipment-form", equipment: %{snmp_port: 162})
|> form("#device-form", device: %{snmp_port: 162})
|> render_submit()
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
assert html =~ "SNMP discovery started in background"
end
@ -576,17 +575,17 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
test "triggers discovery when changing IP address", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
# Change IP
{:ok, _, html} =
view
|> form("#equipment-form", equipment: %{ip_address: "192.168.1.100"})
|> form("#device-form", device: %{ip_address: "192.168.1.100"})
|> render_submit()
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
assert html =~ "SNMP discovery started in background"
end
@ -594,21 +593,21 @@ defmodule ToweropsWeb.EquipmentLive.FormTest do
test "does not trigger discovery for unrelated changes", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
# Change only name
{:ok, _, html} =
view
|> form("#equipment-form", equipment: %{name: "Updated Router"})
|> form("#device-form", device: %{name: "Updated Router"})
|> render_submit()
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
# Should not mention discovery
refute html =~ "SNMP discovery started in background"
assert html =~ "Equipment updated successfully"
assert html =~ "Device updated successfully"
end
end
end

View file

@ -1,9 +1,8 @@
defmodule ToweropsWeb.EquipmentLive.ShowTest do
defmodule ToweropsWeb.DeviceLive.ShowTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
alias Towerops.Equipment
alias Towerops.Monitoring
alias Towerops.Organizations
alias Towerops.Sites
@ -19,78 +18,78 @@ defmodule ToweropsWeb.EquipmentLive.ShowTest do
organization_id: organization.id
})
{:ok, equipment} =
Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
site_id: site.id
})
%{organization: organization, site: site, equipment: equipment}
%{organization: organization, site: site, device: device}
end
describe "Show" do
test "displays equipment information", %{conn: conn, equipment: equipment, organization: org} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/equipment/#{equipment.id}")
test "displays device information", %{conn: conn, device: device, organization: org} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/devices/#{device.id}")
assert html =~ equipment.name
assert html =~ equipment.ip_address
assert html =~ device.name
assert html =~ device.ip_address
end
test "displays overview tab by default", %{conn: conn, equipment: equipment, organization: org} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/equipment/#{equipment.id}")
test "displays overview tab by default", %{conn: conn, device: device, organization: org} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/devices/#{device.id}")
assert html =~ "Overview"
end
test "displays metrics when equipment has checks", %{
test "displays metrics when device has checks", %{
conn: conn,
equipment: equipment,
device: device,
organization: org
} do
# Create some checks
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 10,
checked_at: DateTime.utc_now()
})
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 15,
checked_at: DateTime.utc_now()
})
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :failure,
response_time_ms: nil,
checked_at: DateTime.utc_now()
})
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/equipment/#{equipment.id}")
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/devices/#{device.id}")
assert html =~ "Device Information"
end
test "displays empty state when no checks exist", %{
conn: conn,
equipment: equipment,
device: device,
organization: org
} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/equipment/#{equipment.id}")
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/devices/#{device.id}")
assert html =~ equipment.name
assert html =~ device.name
end
test "refreshes data periodically", %{conn: conn, equipment: equipment, organization: org} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/equipment/#{equipment.id}")
test "refreshes data periodically", %{conn: conn, device: device, organization: org} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/devices/#{device.id}")
# Trigger a status change
Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :failure,
response_time_ms: nil,
checked_at: DateTime.utc_now()
@ -106,9 +105,9 @@ defmodule ToweropsWeb.EquipmentLive.ShowTest do
assert render(view)
end
test "requires authentication", %{equipment: equipment, organization: org} do
test "requires authentication", %{device: device, organization: org} do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/orgs/#{org.slug}/equipment/#{equipment.id}")
{:error, redirect} = live(conn, ~p"/orgs/#{org.slug}/devices/#{device.id}")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
@ -116,13 +115,13 @@ defmodule ToweropsWeb.EquipmentLive.ShowTest do
test "handles equipment_status_changed event", %{
conn: conn,
equipment: equipment,
device: device,
organization: org
} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/equipment/#{equipment.id}")
{:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/devices/#{device.id}")
# Simulate status change event
send(view.pid, {:equipment_status_changed, equipment.id, :up, 25})
send(view.pid, {:device_status_changed, device.id, :up, 25})
# Give it time to process
:timer.sleep(100)
@ -133,13 +132,13 @@ defmodule ToweropsWeb.EquipmentLive.ShowTest do
test "handles discovery_completed event", %{
conn: conn,
equipment: equipment,
device: device,
organization: org
} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/equipment/#{equipment.id}")
{:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/devices/#{device.id}")
# Simulate discovery completed event
send(view.pid, {:discovery_completed, equipment.id})
send(view.pid, {:discovery_completed, device.id})
# Give it time to process
:timer.sleep(100)
@ -148,10 +147,10 @@ defmodule ToweropsWeb.EquipmentLive.ShowTest do
assert html =~ "Discovery completed"
end
test "switches to different tabs", %{conn: conn, equipment: equipment, organization: org} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/equipment/#{equipment.id}?tab=events")
test "switches to different tabs", %{conn: conn, device: device, organization: org} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/devices/#{device.id}?tab=events")
assert html =~ equipment.name
assert html =~ device.name
end
end
end

View file

@ -1,4 +1,4 @@
defmodule ToweropsWeb.EquipmentLiveTest do
defmodule ToweropsWeb.DeviceLiveTest do
use ToweropsWeb.ConnCase
import Mox
@ -35,37 +35,37 @@ defmodule ToweropsWeb.EquipmentLiveTest do
end
describe "Index" do
test "lists all equipment", %{conn: conn, organization: organization, site: site} do
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
test "lists all devices", %{conn: conn, organization: organization, site: site} do
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
description: "Main router",
site_id: site.id
})
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment")
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/devices")
assert html =~ "Equipment"
assert html =~ equipment.name
assert html =~ "Device"
assert html =~ device.name
assert html =~ "192.168.1.1"
end
test "displays empty state when no equipment", %{conn: conn, organization: organization} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment")
test "displays empty state when no device", %{conn: conn, organization: organization} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/devices")
assert html =~ "No equipment"
assert html =~ "No devices"
end
test "has link to add new equipment", %{conn: conn, organization: organization} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment")
test "has link to add new device", %{conn: conn, organization: organization} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/devices")
assert html =~ "New Equipment"
assert html =~ "New Device"
end
test "requires authentication", %{organization: organization} do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/equipment")
{:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/devices")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
@ -74,8 +74,8 @@ defmodule ToweropsWeb.EquipmentLiveTest do
describe "Show" do
setup %{site: site} do
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
description: "Main router",
@ -84,64 +84,64 @@ defmodule ToweropsWeb.EquipmentLiveTest do
check_interval_seconds: 300
})
%{equipment: equipment}
%{device: device}
end
test "displays equipment details", %{
test "displays device details", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
assert html =~ equipment.name
assert html =~ device.name
assert html =~ "192.168.1.1"
assert html =~ "Device Information"
end
test "displays equipment with tab parameter", %{
test "displays device with tab parameter", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
{:ok, _view, html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}?tab=interfaces")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}?tab=interfaces")
assert html =~ equipment.name
assert html =~ device.name
assert html =~ "192.168.1.1"
end
test "shows equipment status when monitoring enabled", %{
test "shows device status when monitoring enabled", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
# Create a monitoring check
{:ok, _check} =
Towerops.Monitoring.create_check(%{
equipment_id: equipment.id,
device_id: device.id,
status: :success,
response_time_ms: 25,
checked_at: DateTime.utc_now()
})
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
# Should display monitoring information
assert html =~ equipment.name
assert html =~ device.name
assert html =~ "192.168.1.1"
end
test "shows SNMP device information when available", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
# Create SNMP device record using Repo
_device =
%Device{}
|> Device.changeset(%{
equipment_id: equipment.id,
device_id: device.id,
sys_descr: "Test Device",
sys_name: "test-device",
manufacturer: "Test Manufacturer",
@ -149,22 +149,22 @@ defmodule ToweropsWeb.EquipmentLiveTest do
})
|> Towerops.Repo.insert!()
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
# Verify the page loads successfully with equipment data
assert html =~ equipment.name
assert html =~ device.name
assert html =~ "192.168.1.1"
end
test "displays recent events", %{
conn: conn,
organization: organization,
equipment: equipment
device: device
} do
# Create an event with all required fields (use valid event_type)
{:ok, _event} =
Towerops.Equipment.create_event(%{
equipment_id: equipment.id,
Towerops.Devices.create_event(%{
device_id: device.id,
event_type: "device_discovered",
severity: "info",
message: "Device was discovered",
@ -172,36 +172,36 @@ defmodule ToweropsWeb.EquipmentLiveTest do
occurred_at: DateTime.utc_now()
})
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
# Verify page loads with events section
assert html =~ equipment.name
assert html =~ device.name
end
test "handles missing equipment gracefully", %{conn: conn, organization: organization} do
test "handles missing device gracefully", %{conn: conn, organization: organization} do
fake_id = Ecto.UUID.generate()
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{fake_id}")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{fake_id}")
end
end
test "deletes equipment", %{conn: conn, organization: organization, equipment: equipment} do
test "deletes device", %{conn: conn, organization: organization, device: device} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
{:ok, _, html} =
view
|> element("button", "Delete Equipment")
|> element("button", "Delete Device")
|> render_click()
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment")
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/devices")
assert html =~ "Equipment deleted successfully"
assert html =~ "Device deleted successfully"
end
test "requires authentication", %{organization: organization, equipment: equipment} do
test "requires authentication", %{organization: organization, device: device} do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
{:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
@ -209,19 +209,19 @@ defmodule ToweropsWeb.EquipmentLiveTest do
end
describe "New" do
test "renders new equipment form", %{conn: conn, organization: organization} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
test "renders new device form", %{conn: conn, organization: organization} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
assert html =~ "New Equipment"
assert html =~ "Add new equipment to monitor"
assert html =~ "New Device"
assert html =~ "Add new device to monitor"
end
test "creates new equipment", %{conn: conn, organization: organization, site: site} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
test "creates new device", %{conn: conn, organization: organization, site: site} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
view
|> form("#equipment-form",
equipment: %{
|> form("#device-form",
device: %{
name: "New Router",
ip_address: "192.168.1.100",
site_id: site.id,
@ -237,32 +237,32 @@ defmodule ToweropsWeb.EquipmentLiveTest do
|> render_submit()
# Verify equipment was created
equipment_list = Towerops.Equipment.list_organization_equipment(organization.id)
refute Enum.empty?(equipment_list)
device_list = Towerops.Devices.list_organization_devices(organization.id)
refute Enum.empty?(device_list)
new_equipment = Enum.find(equipment_list, &(&1.name == "New Router"))
assert new_equipment
assert new_equipment.ip_address == "192.168.1.100"
new_device = Enum.find(device_list, &(&1.name == "New Router"))
assert new_device
assert new_device.ip_address == "192.168.1.100"
end
test "validates required fields", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
html =
view
|> form("#equipment-form", equipment: %{name: "", ip_address: ""})
|> form("#device-form", device: %{name: "", ip_address: ""})
|> render_submit()
assert html =~ "can&#39;t be blank"
end
test "validates IP address format", %{conn: conn, organization: organization, site: site} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
html =
view
|> form("#equipment-form",
equipment: %{
|> form("#device-form",
device: %{
name: "Router",
ip_address: "invalid-ip",
site_id: site.id
@ -279,14 +279,14 @@ defmodule ToweropsWeb.EquipmentLiveTest do
site: site
} do
{:ok, _view, html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/new?site_id=#{site.id}")
live(conn, ~p"/orgs/#{organization.slug}/devices/new?site_id=#{site.id}")
assert html =~ "New Equipment"
assert html =~ "New Device"
end
test "requires authentication", %{organization: organization} do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new")
{:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/devices/new")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
@ -295,48 +295,48 @@ defmodule ToweropsWeb.EquipmentLiveTest do
describe "Edit" do
setup %{site: site} do
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
})
%{equipment: equipment}
%{device: device}
end
test "renders edit form", %{conn: conn, organization: organization, equipment: equipment} do
test "renders edit form", %{conn: conn, organization: organization, device: device} do
{:ok, _view, html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
assert html =~ "Edit Equipment"
assert html =~ "Update equipment details"
assert html =~ "Edit Device"
assert html =~ "Update device details"
end
test "updates equipment", %{conn: conn, organization: organization, equipment: equipment} do
test "updates device", %{conn: conn, organization: organization, device: device} do
{:ok, view, _html} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
{:ok, _, html} =
view
|> form("#equipment-form",
equipment: %{
|> form("#device-form",
device: %{
name: "Updated Router",
ip_address: "192.168.1.2"
}
)
|> render_submit()
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
|> follow_redirect(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}")
assert html =~ "Equipment updated successfully"
assert html =~ "Device updated successfully"
assert html =~ "Updated Router"
end
test "requires authentication", %{organization: organization, equipment: equipment} do
test "requires authentication", %{organization: organization, device: device} do
conn = build_conn()
{:error, redirect} =
live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit")
live(conn, ~p"/orgs/#{organization.slug}/devices/#{device.id}/edit")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"

View file

@ -69,8 +69,8 @@ defmodule ToweropsWeb.SiteLiveTest do
end
test "displays equipment at site", %{conn: conn, organization: organization, site: site} do
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
@ -78,7 +78,7 @@ defmodule ToweropsWeb.SiteLiveTest do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/sites/#{site.id}")
assert html =~ equipment.name
assert html =~ device.name
assert html =~ "192.168.1.1"
end

View file

@ -558,7 +558,7 @@ defmodule ToweropsWeb.UserAuthTest do
refute get_session(conn, :superuser_id)
refute get_session(conn, :target_user_id)
assert conn.assigns.current_scope.user.id == superuser.id
assert redirected_to(conn) == ~p"/orgs/#{organization.slug}/equipment"
assert redirected_to(conn) == ~p"/orgs/#{organization.slug}/devices"
end
test "redirects to /orgs when superuser has no organizations", %{conn: conn} do