towerops/lib/towerops/equipment.ex
Graham McIntire ba1e8933e4
Add hierarchical SNMP configuration with org/site/equipment fallback
Implements a hierarchical SNMP community configuration system where settings
can be defined at the organization level (global default), overridden at the
site level, or overridden at the equipment level.

Changes:
- Migration: Add snmp_version and snmp_community to organizations and sites
- Organization schema: Add SNMP fields with default version "2c"
- Site schema: Add SNMP fields that override organization defaults
- Equipment context: Add get_snmp_config/1 with hierarchical fallback logic
- Discovery: Use Equipment.get_snmp_config instead of equipment.snmp_community
- PollerWorker: Use Equipment.get_snmp_config instead of equipment.snmp_community

The fallback order is:
1. Equipment-level (highest priority)
2. Site-level
3. Organization-level (default)

All 785 tests passing.
2026-01-17 11:47:17 -06:00

300 lines
7 KiB
Elixir

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