towerops/lib/towerops/monitoring/device_monitor.ex

253 lines
6.4 KiB
Elixir

defmodule Towerops.Monitoring.DeviceMonitor do
@moduledoc """
GenServer that monitors a single piece of device by periodically pinging it.
"""
use GenServer
alias Ecto.Adapters.SQL.Sandbox
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Monitoring
alias Towerops.Repo
require Logger
# Allow dependency injection for testing
@ping_module Application.compile_env(:towerops, :ping_module, Towerops.Monitoring.Ping)
# Suppress warnings for Mox modules that are defined at runtime during tests
@compile {:no_warn_undefined, Towerops.Monitoring.PingMock}
# Client API
@doc """
Starts a monitor for the given device ID.
"""
def start_link(opts) do
device_id = Keyword.fetch!(opts, :device_id)
lease_id = Keyword.get(opts, :lease_id)
GenServer.start_link(__MODULE__, {device_id, lease_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 (node-local lookup)
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), enqueue check job
enqueue_check(device_id)
end
end
# Server Callbacks
@impl true
def init({device_id, lease_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, lease_id: lease_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)
# For SNMP-enabled devices, use recent SNMP poll success as health indicator
# For ICMP-only devices, use ping
check_result = check_device_health(device)
now = DateTime.truncate(DateTime.utc_now(), :second)
{status, response_time_ms} =
case check_result do
{:ok, time} -> {:success, time}
{:error, _reason} -> {:failure, nil}
end
# Save the check result
case Monitoring.create_check(%{
device_id: device_id,
status: status,
response_time_ms: response_time_ms,
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 check_device_health(device) do
# Always perform actual ICMP ping to measure real latency
# Don't rely solely on SNMP poll success since that doesn't give us latency data
@ping_module.ping(device.ip_address)
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
# Enqueue monitoring check job - safe to call in test environment
defp enqueue_check(device_id) do
if Application.get_env(:towerops, :env) == :test do
# In test, run synchronously with database sandbox access
start_check_task(device_id)
else
# In dev/prod, enqueue to Exq
{:ok, _job} = Exq.enqueue(Exq, "monitoring", Towerops.Workers.MonitorWorker, [device_id])
end
end
defp start_check_task(device_id) do
parent = self()
_ =
Task.start(fn ->
_ = maybe_allow_sandbox(parent)
perform_check(device_id)
end)
end
defp maybe_allow_sandbox(parent) do
if Application.get_env(:towerops, :sql_sandbox) do
Sandbox.allow(Repo, parent, self())
end
end
defp via_tuple(device_id) do
{:via, Registry, {Towerops.Monitoring.Registry, device_id}}
end
end