towerops/lib/towerops/devices.ex
2026-01-26 10:22:12 -06:00

463 lines
11 KiB
Elixir

defmodule Towerops.Devices do
@moduledoc """
The Devices context.
"""
import Ecto.Query
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Devices.Event
alias Towerops.Repo
alias Towerops.Workers.DeviceMonitorWorker
alias Towerops.Workers.DevicePollerWorker
@doc """
Returns the list of devices for a site.
Ordered by custom display_order (if set), then alphabetically by name.
"""
def list_site_devices(site_id) do
Repo.all(
from(e in DeviceSchema,
where: e.site_id == ^site_id,
order_by: [asc: e.display_order, asc: e.name]
)
)
end
@doc """
Returns the list of all devices for an organization (via sites).
Ordered by custom display_order (if set), then alphabetically by name.
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.display_order, 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 by ID, returns nil if not found.
"""
def get_device(id) do
DeviceSchema
|> Repo.get(id)
|> case do
nil -> nil
device -> Repo.preload(device, [:site])
end
end
@doc """
Gets a single device by ID, raises if not found.
"""
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 """
Finds a device by IP address within a site, optionally excluding a specific device ID.
Returns nil if no device found.
"""
def get_device_by_ip(ip_address, site_id, exclude_id \\ nil)
def get_device_by_ip(_ip_address, nil, _exclude_id), do: nil
def get_device_by_ip(ip_address, site_id, exclude_id) do
query =
from(d in DeviceSchema,
where: d.ip_address == ^ip_address and d.site_id == ^site_id,
preload: [:site]
)
query =
if exclude_id do
from(d in query, where: d.id != ^exclude_id)
else
query
end
Repo.one(query)
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
device = ensure_snmp_config_loaded(device)
resolve_snmp_config(device)
end
defp ensure_snmp_config_loaded(device) do
# Ensure site and organization associations are loaded
needs_preload =
!Ecto.assoc_loaded?(device.site) ||
!Ecto.assoc_loaded?(device.site.organization)
if needs_preload do
Repo.preload(device, site: :organization)
else
device
end
end
defp resolve_snmp_config(device) do
# Resolve community and version independently with hierarchical fallback
# This allows setting version at device level while inheriting community from org
community =
device.snmp_community ||
device.site.snmp_community ||
device.site.organization.snmp_community
version =
device.snmp_version ||
device.site.snmp_version ||
device.site.organization.snmp_version ||
"2c"
source = determine_snmp_source(device)
%{
version: version,
community: community,
source: source
}
end
defp determine_snmp_source(device) do
# Determine source based on where community string came from (primary credential)
# If no community anywhere, source is :default
cond do
device.snmp_community != nil -> :device
device.site.snmp_community != nil -> :site
device.site.organization.snmp_community != nil -> :organization
true -> :default
end
end
@doc """
Creates device.
"""
def create_device(attrs) do
case %DeviceSchema{}
|> DeviceSchema.changeset(attrs)
|> Repo.insert() do
{:ok, device} = result ->
# Start monitoring/polling if enabled
_ =
if device.monitoring_enabled do
DeviceMonitorWorker.start_monitoring(device.id)
end
_ =
if device.snmp_enabled do
DevicePollerWorker.start_polling(device.id)
end
result
error ->
error
end
end
@doc """
Updates device.
"""
def update_device(%DeviceSchema{} = device, attrs) do
old_monitoring = device.monitoring_enabled
old_snmp = device.snmp_enabled
case device
|> DeviceSchema.changeset(attrs)
|> Repo.update() do
{:ok, updated_device} = result ->
_ = handle_monitoring_changes(updated_device, old_monitoring)
_ = handle_snmp_changes(updated_device, old_snmp)
result
error ->
error
end
end
@doc """
Deletes device.
"""
def delete_device(%DeviceSchema{} = device) do
# Stop monitoring and polling jobs before deleting
_ = DeviceMonitorWorker.stop_monitoring(device.id)
_ = DevicePollerWorker.stop_polling(device.id)
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
# Private helpers for monitoring/polling management
defp handle_monitoring_changes(device, old_monitoring) do
cond do
device.monitoring_enabled && !old_monitoring ->
DeviceMonitorWorker.start_monitoring(device.id)
!device.monitoring_enabled && old_monitoring ->
DeviceMonitorWorker.stop_monitoring(device.id)
true ->
:ok
end
end
defp handle_snmp_changes(device, old_snmp) do
cond do
device.snmp_enabled && !old_snmp ->
DevicePollerWorker.start_polling(device.id)
!device.snmp_enabled && old_snmp ->
DevicePollerWorker.stop_polling(device.id)
true ->
:ok
end
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
## Display Order Management
@doc """
Reorders a device to a new position within its site.
Updates display_order for all devices in the site to maintain continuous ordering (1, 2, 3, ...).
The new_position is 1-based (1 = first position).
Returns {:ok, device} on success, {:error, changeset} on failure.
"""
def reorder_device(device_id, new_position) when is_integer(new_position) and new_position > 0 do
Repo.transaction(fn ->
device = Repo.get!(DeviceSchema, device_id)
site_id = device.site_id
# Get all devices in site excluding the one being moved, in current order
other_devices =
Repo.all(
from(d in DeviceSchema,
where: d.site_id == ^site_id and d.id != ^device_id,
order_by: [asc: d.display_order, asc: d.name]
)
)
# Insert at new position (1-based index, convert to 0-based for List.insert_at)
new_order = List.insert_at(other_devices, new_position - 1, device)
# Update display_order for all devices
new_order
|> Enum.with_index(1)
|> Enum.each(fn {d, order} ->
Repo.update!(Ecto.Changeset.change(d, display_order: order))
end)
Repo.get!(DeviceSchema, device_id)
end)
end
@doc """
Reset all devices in organization to alphabetical order.
Clears display_order field for all devices, allowing them to fall back to alphabetical sorting.
"""
def reset_organization_device_order(organization_id) do
Repo.update_all(
from(d in DeviceSchema,
join: s in assoc(d, :site),
where: s.organization_id == ^organization_id
),
set: [display_order: nil, updated_at: DateTime.truncate(DateTime.utc_now(), :second)]
)
end
end