## Summary - **DB pool exhaustion**: `device_has_effective_agent?` was doing 2 DB queries per call (3-table join + global settings lookup), called 2-3x per worker cycle across hundreds of devices (~6000 unnecessary queries/minute). Added an ETS-backed `AgentCache` with 120s TTL and removed redundant calls within the same worker cycle. - **Settings page crash**: `KeyError: key :user_agent not found` on BrowserSession — field doesn't exist, replaced with `device_type`. - **Login history IP truncation**: Changed from side-by-side grid to stacked table layout so IP addresses aren't clipped. ## Test plan - [x] 8 new tests for AgentCache (cache hit, miss, invalidation, global default) - [x] Full test suite passes (8651 tests, 0 failures, verified across multiple runs) - [x] `mix format` and pre-commit hooks pass Reviewed-on: graham/towerops-web#88
248 lines
6.9 KiB
Elixir
248 lines
6.9 KiB
Elixir
defmodule Towerops.Workers.CheckWorker do
|
|
@moduledoc """
|
|
Oban worker that executes service checks (HTTP, TCP, DNS).
|
|
|
|
Follows same self-scheduling pattern as DeviceMonitorWorker.
|
|
Phoenix executes checks unless assigned to an agent (agent_token_id set).
|
|
"""
|
|
use Oban.Worker,
|
|
queue: :checks,
|
|
unique: [
|
|
period: 60,
|
|
keys: [:check_id],
|
|
states: [:available, :scheduled, :executing, :retryable]
|
|
]
|
|
|
|
alias Towerops.Agents
|
|
alias Towerops.Alerts
|
|
alias Towerops.Monitoring
|
|
alias Towerops.Monitoring.Executor
|
|
alias Towerops.Snmp.Client
|
|
alias Towerops.Workers.PollingOffset
|
|
|
|
require Logger
|
|
|
|
@impl Oban.Worker
|
|
def perform(%Oban.Job{args: %{"check_id" => check_id}}) do
|
|
case Monitoring.get_check(check_id) do
|
|
nil ->
|
|
Logger.debug("Check #{check_id} no longer exists, skipping and not rescheduling")
|
|
:ok
|
|
|
|
check ->
|
|
if should_continue_checking?(check) do
|
|
maybe_perform_check(check)
|
|
schedule_next_check_with_error_handling(check_id, check.interval_seconds)
|
|
else
|
|
Logger.debug("Check #{check_id} disabled or assigned to agent, not rescheduling",
|
|
check_id: check_id,
|
|
enabled: check.enabled,
|
|
agent_assigned: !is_nil(check.agent_token_id)
|
|
)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
end
|
|
|
|
# Check should continue if:
|
|
# - Enabled
|
|
# - Not a passive check (passive checks don't execute actively)
|
|
# - Either no agent assigned OR agent is cloud poller (Phoenix executes)
|
|
defp should_continue_checking?(check) do
|
|
check.enabled &&
|
|
check.check_type != "passive" &&
|
|
!Client.phoenix_snmp_disabled() &&
|
|
should_phoenix_execute?(check)
|
|
end
|
|
|
|
defp should_phoenix_execute?(check) do
|
|
# Never execute if the device has any effective agent at any cascade level
|
|
if check.device_id != nil and Agents.device_has_effective_agent?(check.device_id) do
|
|
false
|
|
else
|
|
case check.agent_token_id do
|
|
nil ->
|
|
true
|
|
|
|
agent_token_id ->
|
|
try do
|
|
agent_token = Agents.get_agent_token!(agent_token_id)
|
|
agent_token.is_cloud_poller
|
|
rescue
|
|
Ecto.NoResultsError -> true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# Agent check already done by should_continue_checking?, no need to re-check
|
|
defp maybe_perform_check(check) do
|
|
perform_check(check)
|
|
end
|
|
|
|
defp schedule_next_check_with_error_handling(check_id, interval_seconds) do
|
|
case Monitoring.get_check(check_id) do
|
|
nil ->
|
|
Logger.debug("Check deleted, not rescheduling", check_id: check_id)
|
|
:ok
|
|
|
|
_check ->
|
|
case schedule_next_check(check_id, interval_seconds) do
|
|
{:ok, _job} ->
|
|
:ok
|
|
|
|
{:error, changeset} ->
|
|
Logger.error("Failed to schedule next check for #{check_id}: #{inspect(changeset.errors)}")
|
|
:ok
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Starts executing a check (schedules first execution).
|
|
"""
|
|
def start_check(check_id, interval_seconds \\ 60) do
|
|
offset = PollingOffset.calculate_offset(check_id, interval_seconds)
|
|
|
|
%{check_id: check_id}
|
|
|> new(schedule_in: offset)
|
|
|> Oban.insert()
|
|
end
|
|
|
|
@doc """
|
|
Stops executing a check by cancelling its jobs.
|
|
"""
|
|
def stop_check(check_id) do
|
|
import Ecto.Query
|
|
|
|
Oban.cancel_all_jobs(
|
|
from(j in Oban.Job,
|
|
where: j.worker == "Towerops.Workers.CheckWorker",
|
|
where: fragment("args->>'check_id' = ?", ^check_id),
|
|
where: j.state in ["available", "scheduled", "executing", "retryable"]
|
|
)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Triggers an immediate check execution.
|
|
"""
|
|
def trigger_check(check_id) do
|
|
%{check_id: check_id}
|
|
|> new()
|
|
|> Oban.insert()
|
|
end
|
|
|
|
# Private Functions
|
|
|
|
defp perform_check(check) do
|
|
Logger.debug("Executing check: #{check.name} (#{check.check_type})", check_id: check.id)
|
|
|
|
# Execute the check
|
|
result = Executor.execute(check)
|
|
|
|
# Convert result to status and extract details
|
|
{status, response_time, output} =
|
|
case result do
|
|
{:ok, time, msg} -> {0, time, msg}
|
|
{:error, reason} -> {2, nil, reason}
|
|
end
|
|
|
|
# Record the result
|
|
now = DateTime.utc_now()
|
|
|
|
case Monitoring.create_check_result(%{
|
|
check_id: check.id,
|
|
organization_id: check.organization_id,
|
|
status: status,
|
|
output: output,
|
|
response_time_ms: response_time,
|
|
checked_at: now
|
|
}) do
|
|
{:ok, _result} ->
|
|
Logger.debug("Recorded check result: #{output}", check_id: check.id, status: status)
|
|
|
|
{:error, changeset} ->
|
|
Logger.error("Failed to create check result: #{inspect(changeset.errors)}")
|
|
end
|
|
|
|
# Update check state (handles soft/hard state transitions)
|
|
old_state = check.current_state
|
|
{:ok, updated_check} = Monitoring.update_check_state(check, status, output)
|
|
|
|
# Handle state changes (create/resolve alerts)
|
|
if state_changed_to_problem?(old_state, updated_check) do
|
|
handle_check_problem(updated_check, output)
|
|
end
|
|
|
|
if state_changed_to_ok?(old_state, updated_check) do
|
|
handle_check_recovery(updated_check)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
defp state_changed_to_problem?(old_state, check) do
|
|
# State changed from OK to CRITICAL/WARNING and is now hard state
|
|
old_state == 0 && check.current_state in [1, 2] && check.current_state_type == "hard"
|
|
end
|
|
|
|
defp state_changed_to_ok?(old_state, check) do
|
|
# State changed from CRITICAL/WARNING to OK
|
|
old_state in [1, 2] && check.current_state == 0
|
|
end
|
|
|
|
defp handle_check_problem(check, output) do
|
|
Logger.warning("Check #{check.name} entered problem state",
|
|
check_id: check.id,
|
|
state: check.current_state,
|
|
output: output
|
|
)
|
|
|
|
# Create alert if one doesn't exist
|
|
if !Alerts.has_active_check_alert?(check.id) do
|
|
create_check_alert(check, output)
|
|
end
|
|
end
|
|
|
|
defp handle_check_recovery(check) do
|
|
Logger.info("Check #{check.name} recovered",
|
|
check_id: check.id
|
|
)
|
|
|
|
# Resolve any active alerts
|
|
Alerts.resolve_check_alerts(check.id)
|
|
end
|
|
|
|
defp create_check_alert(check, output) do
|
|
severity = if check.current_state == 1, do: 1, else: 2
|
|
|
|
attrs = %{
|
|
check_id: check.id,
|
|
device_id: check.device_id,
|
|
organization_id: check.organization_id,
|
|
alert_type: "check_#{check.check_type}",
|
|
severity: severity,
|
|
message: "Check '#{check.name}' is #{Monitoring.Check.state_label(check.current_state)}",
|
|
output: output,
|
|
triggered_at: DateTime.utc_now()
|
|
}
|
|
|
|
case Alerts.create_alert(attrs) do
|
|
{:ok, alert} ->
|
|
Logger.info("Created alert for check #{check.name}", alert_id: alert.id)
|
|
|
|
{:error, changeset} ->
|
|
Logger.error("Failed to create alert: #{inspect(changeset.errors)}")
|
|
end
|
|
end
|
|
|
|
defp schedule_next_check(check_id, interval_seconds) do
|
|
offset = PollingOffset.calculate_offset(check_id, interval_seconds)
|
|
|
|
%{check_id: check_id}
|
|
|> new(schedule_in: offset)
|
|
|> Oban.insert()
|
|
end
|
|
end
|