Removes unnecessary two-layer architecture (Oban Coordinator → GenServer) in favor of direct Oban workers that perform the actual work. Changes: - Replace DevicePollerCoordinator + PollerWorker with DevicePollerWorker - Replace DeviceMonitorCoordinator + DeviceMonitor with DeviceMonitorWorker - Simplify Monitoring.Supervisor (removed Registries, DynamicSupervisors) - Remove all Exq dependencies (workers, supervisor, mix deps) - Convert DiscoveryWorker from Exq to Oban pattern - Update Devices context to auto-start/stop monitoring and polling - Update all LiveView callers to use new Oban enqueue pattern - Fix all tests to use Oban.Job struct instead of raw IDs Benefits: - Simpler codebase (~850 lines removed) - Better reliability (Oban handles retries, failures) - Lower memory (no persistent GenServers) - Better observability (work visible in Oban dashboard) - Cluster-wide coordination via PostgreSQL - Extensive PubSub usage for real-time events Files deleted: - lib/towerops/workers/device_monitor_coordinator.ex - lib/towerops/workers/device_poller_coordinator.ex - lib/towerops/snmp/poller_worker.ex - lib/towerops/monitoring/device_monitor.ex - lib/towerops/workers/monitor_worker.ex - lib/towerops/workers/poll_worker.ex - lib/towerops/exq_supervisor.ex - All related test files Files created: - lib/towerops/workers/device_poller_worker.ex - lib/towerops/workers/device_monitor_worker.ex All 3,686 tests passing.
213 lines
5 KiB
Elixir
213 lines
5 KiB
Elixir
defmodule Towerops.Workers.DeviceMonitorWorker do
|
|
@moduledoc """
|
|
Oban worker that monitors device health by pinging it.
|
|
|
|
Uses Oban's unique job feature to ensure only one monitor per device
|
|
runs cluster-wide. Monitors check device connectivity and create alerts
|
|
on status changes.
|
|
"""
|
|
use Oban.Worker,
|
|
queue: :monitors,
|
|
unique: [
|
|
period: 60,
|
|
keys: [:device_id],
|
|
states: [:available, :scheduled, :executing, :retryable]
|
|
]
|
|
|
|
alias Towerops.Alerts
|
|
alias Towerops.Devices
|
|
alias Towerops.Monitoring
|
|
|
|
require Logger
|
|
|
|
@ping_module Application.compile_env(:towerops, :ping_module, Towerops.Monitoring.Ping)
|
|
@compile {:no_warn_undefined, Towerops.Monitoring.PingMock}
|
|
|
|
@monitor_interval 30
|
|
|
|
@impl Oban.Worker
|
|
def perform(%Oban.Job{args: %{"device_id" => device_id}}) do
|
|
case Devices.get_device(device_id) do
|
|
nil ->
|
|
Logger.debug("Device #{device_id} no longer exists, skipping monitor")
|
|
:ok
|
|
|
|
device ->
|
|
if device.monitoring_enabled do
|
|
perform_check(device)
|
|
end
|
|
|
|
# Schedule next check
|
|
schedule_next_check(device_id)
|
|
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Starts monitoring for a device.
|
|
"""
|
|
def start_monitoring(device_id) do
|
|
%{device_id: device_id}
|
|
|> new()
|
|
|> Oban.insert()
|
|
end
|
|
|
|
@doc """
|
|
Stops monitoring for a device by cancelling its jobs.
|
|
"""
|
|
def stop_monitoring(device_id) do
|
|
import Ecto.Query
|
|
|
|
Oban.cancel_all_jobs(
|
|
from(j in Oban.Job,
|
|
where: j.worker == "Towerops.Workers.DeviceMonitorWorker",
|
|
where: fragment("args->>'device_id' = ?", ^device_id),
|
|
where: j.state in ["available", "scheduled", "executing", "retryable"]
|
|
)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Triggers an immediate check for a device.
|
|
"""
|
|
def trigger_check(device_id) do
|
|
%{device_id: device_id}
|
|
|> new()
|
|
|> Oban.insert()
|
|
end
|
|
|
|
# Private Functions
|
|
|
|
defp perform_check(device) do
|
|
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
|
|
|
|
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
|
|
|
|
new_status = if status == :success, do: :up, else: :down
|
|
old_status = device.status
|
|
|
|
_ = Devices.update_device_status(device, new_status)
|
|
|
|
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}
|
|
)
|
|
end
|
|
|
|
defp check_device_health(device) do
|
|
@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(device_id) do
|
|
%{device_id: device_id}
|
|
|> new(schedule_in: @monitor_interval)
|
|
|> Oban.insert()
|
|
end
|
|
end
|