feat: add check creation from discovery and backfill tool (Phase 5)

Completes Phase 5 of the unified checks system by enabling automatic check
creation during SNMP discovery and providing a backfill tool for existing devices.

Discovery Integration:
- create_checks_from_discovery/2: Creates checks for all discovered entities
  (sensors, interfaces, processors, storage) after SNMP discovery completes
- Links each check to its source entity via source_id for data lookup
- Returns detailed results with counts per type and error tracking
- Logs creation results with total counts and any failures

Helper Functions:
- create_sensor_check/2: Creates snmp_sensor checks with config
- create_interface_check/2: Creates snmp_interface checks
- create_processor_check/2: Creates snmp_processor checks
- create_storage_check/2: Creates snmp_storage checks

All checks configured with:
- 60-second poll intervals (default)
- Auto-discovery source type
- Enabled by default
- Type-specific configuration in JSONB config field

Backfill Tool:
- mix backfill.checks: Creates checks for existing SNMP-enabled devices
- Supports --dry-run mode for preview
- Shows counts per device (sensors, interfaces, processors, storage)
- Reports success/failure counts and detailed errors
- Safely handles devices without discovery data
- Reduced nesting depth using cond and helper functions for credo compliance

This enables the unified monitoring system to work with both new discoveries
and existing devices, ensuring all SNMP data can be monitored through the
unified checks interface.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Graham McIntire 2026-02-12 17:04:06 -06:00
parent d7741cdc0e
commit 75d64d8772
No known key found for this signature in database
4 changed files with 304 additions and 20 deletions

View file

@ -1,3 +1,17 @@
2026-02-12
feat: add check creation from SNMP discovery and backfill task (Phase 5)
- Implemented create_checks_from_discovery/2 in Discovery module to auto-create
checks for all discovered SNMP entities (sensors, interfaces, processors, storage)
- Discovery now creates check records enabling unified monitoring after completing
SNMP discovery, linking checks to source entities via source_id
- Added mix backfill.checks task to create checks for existing devices with
SNMP discovery data, includes dry-run mode and comprehensive error reporting
- Returns detailed results map with counts per entity type and any errors
- Private helper functions: create_sensor_check/2, create_interface_check/2,
create_processor_check/2, create_storage_check/2
- All checks default to 60-second intervals, enabled state, auto_discovery source
- Files: lib/towerops/snmp/discovery.ex, lib/mix/tasks/backfill_checks.ex
2026-02-12
docs: add Terraform provider documentation to public API docs
- Added comprehensive Terraform provider section to /docs/api page with navigation link

View file

@ -0,0 +1,103 @@
defmodule Mix.Tasks.Backfill.Checks do
@shortdoc "Backfill checks for existing SNMP-enabled devices"
@moduledoc """
Backfill checks for existing devices with SNMP discovery data.
This task creates check records for all devices that have existing
snmp_device records with sensors, interfaces, processors, or storage.
Usage:
mix backfill.checks
mix backfill.checks --dry-run
"""
use Mix.Task
import Ecto.Query
alias Towerops.Devices
alias Towerops.Repo
alias Towerops.Snmp.Discovery
require Logger
@impl Mix.Task
def run(args) do
Mix.Task.run("app.start")
dry_run = "--dry-run" in args
if dry_run do
IO.puts("=== DRY RUN MODE - No changes will be made ===\n")
end
devices =
Repo.all(
from d in Devices.Device,
where: d.snmp_enabled == true,
preload: [:organization, snmp_device: [:sensors, :interfaces, :processors, :storage]]
)
IO.puts("Found #{length(devices)} SNMP-enabled devices\n")
Enum.each(devices, fn device ->
if device.snmp_device do
backfill_device_checks(device, dry_run)
else
IO.puts("Skipping #{device.name} - no SNMP discovery data")
end
end)
IO.puts("\n=== Backfill complete ===")
end
defp backfill_device_checks(device, dry_run) do
snmp_device = device.snmp_device
sensor_count = length(snmp_device.sensors)
interface_count = length(snmp_device.interfaces)
processor_count = length(snmp_device.processors)
storage_count = length(snmp_device.storage)
total = sensor_count + interface_count + processor_count + storage_count
cond do
total == 0 ->
IO.puts("Skipping #{device.name} - no discovered entities")
dry_run ->
print_device_summary(device, sensor_count, interface_count, processor_count, storage_count)
IO.puts(" [DRY RUN] Would create #{total} checks")
true ->
print_device_summary(device, sensor_count, interface_count, processor_count, storage_count)
create_and_report_checks(device, snmp_device)
end
end
defp print_device_summary(device, sensors, interfaces, processors, storage) do
IO.puts("\n#{device.name} (#{device.ip_address}):")
IO.puts(" - #{sensors} sensors")
IO.puts(" - #{interfaces} interfaces")
IO.puts(" - #{processors} processors")
IO.puts(" - #{storage} storage")
end
defp create_and_report_checks(device, snmp_device) do
result = Discovery.create_checks_from_discovery(device, snmp_device)
created = result.sensors + result.interfaces + result.processors + result.storage
if result.errors == [] do
IO.puts(" ✓ Created #{created} checks")
else
IO.puts(" ✓ Created #{created} checks (#{length(result.errors)} errors)")
print_errors(result.errors)
end
end
defp print_errors(errors) do
Enum.each(errors, fn error ->
IO.puts("#{error.type}: #{error.message}")
end)
end
end

View file

@ -418,8 +418,194 @@ defmodule Towerops.Snmp.Discovery do
{:ok, results}
end
@doc """
Creates checks for all discovered SNMP entities (sensors, interfaces, processors, storage).
This function is called after SNMP discovery completes to create check records
that enable monitoring of the discovered entities.
## Parameters
- device: The Device schema struct
- snmp_device: The SnmpDevice with preloaded sensors, interfaces, processors, storage
## Returns
A map with counts of created checks and any errors:
```
%{
sensors: 10,
interfaces: 5,
processors: 2,
storage: 3,
errors: []
}
```
"""
def create_checks_from_discovery(device, snmp_device) do
alias Towerops.Monitoring
# Ensure associations are loaded
snmp_device = Repo.preload(snmp_device, [:sensors, :interfaces, :processors, :storage])
result = %{
sensors: 0,
interfaces: 0,
processors: 0,
storage: 0,
errors: []
}
# Create checks for sensors
result =
Enum.reduce(snmp_device.sensors, result, fn sensor, acc ->
case create_sensor_check(device, sensor) do
{:ok, _check} -> %{acc | sensors: acc.sensors + 1}
{:error, reason} -> %{acc | errors: [%{type: "sensor", id: sensor.id, message: inspect(reason)} | acc.errors]}
end
end)
# Create checks for interfaces
result =
Enum.reduce(snmp_device.interfaces, result, fn interface, acc ->
case create_interface_check(device, interface) do
{:ok, _check} ->
%{acc | interfaces: acc.interfaces + 1}
{:error, reason} ->
%{acc | errors: [%{type: "interface", id: interface.id, message: inspect(reason)} | acc.errors]}
end
end)
# Create checks for processors
result =
Enum.reduce(snmp_device.processors, result, fn processor, acc ->
case create_processor_check(device, processor) do
{:ok, _check} ->
%{acc | processors: acc.processors + 1}
{:error, reason} ->
%{acc | errors: [%{type: "processor", id: processor.id, message: inspect(reason)} | acc.errors]}
end
end)
# Create checks for storage
result =
Enum.reduce(snmp_device.storage, result, fn storage, acc ->
case create_storage_check(device, storage) do
{:ok, _check} -> %{acc | storage: acc.storage + 1}
{:error, reason} -> %{acc | errors: [%{type: "storage", id: storage.id, message: inspect(reason)} | acc.errors]}
end
end)
# Log results
log_check_creation_results(device.id, result)
result
end
# Private functions
defp create_sensor_check(device, sensor) do
alias Towerops.Monitoring
Monitoring.create_check(%{
organization_id: device.organization_id,
device_id: device.id,
name: sensor.sensor_descr,
check_type: "snmp_sensor",
source_type: "auto_discovery",
source_id: sensor.id,
interval_seconds: 60,
enabled: true,
config: %{
"sensor_type" => sensor.sensor_type,
"sensor_class" => sensor.sensor_class,
"sensor_oid" => sensor.sensor_oid,
"sensor_divisor" => sensor.sensor_divisor,
"sensor_unit" => sensor.sensor_unit
}
})
end
defp create_interface_check(device, interface) do
alias Towerops.Monitoring
Monitoring.create_check(%{
organization_id: device.organization_id,
device_id: device.id,
name: "Interface #{interface.if_descr}",
check_type: "snmp_interface",
source_type: "auto_discovery",
source_id: interface.id,
interval_seconds: 60,
enabled: true,
config: %{
"if_index" => interface.if_index,
"if_descr" => interface.if_descr
}
})
end
defp create_processor_check(device, processor) do
alias Towerops.Monitoring
Monitoring.create_check(%{
organization_id: device.organization_id,
device_id: device.id,
name: "CPU #{processor.processor_index}",
check_type: "snmp_processor",
source_type: "auto_discovery",
source_id: processor.id,
interval_seconds: 60,
enabled: true,
config: %{
"processor_index" => processor.processor_index,
"processor_descr" => processor.processor_descr
}
})
end
defp create_storage_check(device, storage) do
alias Towerops.Monitoring
Monitoring.create_check(%{
organization_id: device.organization_id,
device_id: device.id,
name: storage.description || storage.device_name || "Storage #{storage.storage_index}",
check_type: "snmp_storage",
source_type: "auto_discovery",
source_id: storage.id,
interval_seconds: 60,
enabled: true,
config: %{
"storage_index" => storage.storage_index,
"storage_descr" => storage.description
}
})
end
defp log_check_creation_results(device_id, check_counts) do
total_checks =
check_counts.sensors +
check_counts.interfaces +
check_counts.processors +
check_counts.storage
Logger.info(
"Created #{total_checks} checks for device #{device_id}: " <>
"#{check_counts.sensors} sensors, #{check_counts.interfaces} interfaces, " <>
"#{check_counts.processors} processors, #{check_counts.storage} storage",
device_id: device_id
)
if check_counts.errors != [] do
Logger.warning(
"Failed to create #{length(check_counts.errors)} checks for device #{device_id}",
device_id: device_id,
errors: check_counts.errors
)
end
end
@spec build_client_opts(DeviceSchema.t()) :: Client.connection_opts()
defp build_client_opts(device) do
# Get SNMP config with hierarchical fallback (device -> site -> organization)
@ -1191,26 +1377,6 @@ defmodule Towerops.Snmp.Discovery do
:ok
end
defp log_check_creation_results(device_id, check_counts) do
total_checks =
check_counts.sensors + check_counts.interfaces + check_counts.processors + check_counts.storage
Logger.info(
"Created #{total_checks} checks: " <>
"#{check_counts.sensors} sensors, #{check_counts.interfaces} interfaces, " <>
"#{check_counts.processors} processors, #{check_counts.storage} storage",
device_id: device_id
)
if check_counts.errors != [] do
Logger.warning(
"Failed to create #{length(check_counts.errors)} checks",
device_id: device_id,
errors: check_counts.errors
)
end
end
defp log_discovery_event(device, discovered_device, is_rediscovery) do
event_type = if is_rediscovery, do: "device_rediscovered", else: "device_discovered"

View file

@ -9,6 +9,7 @@ Devices Tested & Working
* Device checks tab showing all monitored items (sensors, interfaces, services) with status indicators
* Manual service check creation for HTTP/HTTPS, TCP port, and DNS monitoring
* Automatic check creation from SNMP discovery (sensors, interfaces, processors, storage)
* Backfill tool to create checks for existing devices with SNMP data
* Unified time-series graphing across all check types
* Enhanced monitoring infrastructure with improved reliability
* Fixed SNMP monitoring credential resolution for all check types