- Health status dot (green/yellow/red/gray) from worst-case check state - Relative 'last seen' with color-coded staleness thresholds - Response time badge from latest monitoring check - Subscriber count badge from Gaiia inventory items - Batch queries to avoid N+1: Monitoring.get_device_health_summary/1, Monitoring.get_device_latest_response_times/1, Gaiia.get_device_subscriber_counts/1
369 lines
10 KiB
Elixir
369 lines
10 KiB
Elixir
defmodule Towerops.Monitoring do
|
|
@moduledoc """
|
|
The Monitoring context for managing checks and check results.
|
|
|
|
Follows Icinga2's generic check pattern with type-specific configuration.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Towerops.Monitoring.Check
|
|
alias Towerops.Monitoring.CheckResult
|
|
alias Towerops.Monitoring.MonitoringCheck
|
|
alias Towerops.Repo
|
|
|
|
## Checks
|
|
|
|
@doc """
|
|
Returns the list of checks for an organization.
|
|
"""
|
|
def list_checks(organization_id, opts \\ []) do
|
|
query =
|
|
from c in Check,
|
|
where: c.organization_id == ^organization_id,
|
|
order_by: [asc: c.name]
|
|
|
|
query
|
|
|> maybe_filter_by_device(opts[:device_id])
|
|
|> maybe_filter_by_check_type(opts[:check_type])
|
|
|> maybe_filter_by_enabled(opts[:enabled])
|
|
|> Repo.all()
|
|
end
|
|
|
|
defp maybe_filter_by_device(query, nil), do: query
|
|
|
|
defp maybe_filter_by_device(query, device_id) do
|
|
from c in query, where: c.device_id == ^device_id
|
|
end
|
|
|
|
defp maybe_filter_by_check_type(query, nil), do: query
|
|
|
|
defp maybe_filter_by_check_type(query, check_type) do
|
|
from c in query, where: c.check_type == ^check_type
|
|
end
|
|
|
|
defp maybe_filter_by_enabled(query, nil), do: query
|
|
|
|
defp maybe_filter_by_enabled(query, enabled) do
|
|
from c in query, where: c.enabled == ^enabled
|
|
end
|
|
|
|
@doc """
|
|
Gets a single check.
|
|
"""
|
|
def get_check(id), do: Repo.get(Check, id)
|
|
|
|
@doc """
|
|
Gets a single check, raises if not found.
|
|
"""
|
|
def get_check!(id), do: Repo.get!(Check, id)
|
|
|
|
@doc """
|
|
Gets a check by check_key (for passive checks).
|
|
"""
|
|
def get_check_by_key(check_key) do
|
|
Repo.get_by(Check, check_key: check_key)
|
|
end
|
|
|
|
@doc """
|
|
Creates a check.
|
|
"""
|
|
def create_check(attrs \\ %{}) do
|
|
%Check{}
|
|
|> Check.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Updates a check.
|
|
"""
|
|
def update_check(%Check{} = check, attrs) do
|
|
check
|
|
|> Check.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Deletes a check.
|
|
"""
|
|
def delete_check(%Check{} = check) do
|
|
Repo.delete(check)
|
|
end
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for tracking check changes.
|
|
"""
|
|
def change_check(%Check{} = check, attrs \\ %{}) do
|
|
Check.changeset(check, attrs)
|
|
end
|
|
|
|
@doc """
|
|
Schedules a check for execution.
|
|
|
|
Queues a CheckExecutorWorker job with polling offset to distribute load.
|
|
"""
|
|
def schedule_check(%Check{} = check) do
|
|
alias Towerops.Workers.CheckExecutorWorker
|
|
alias Towerops.Workers.PollingOffset
|
|
|
|
offset = PollingOffset.calculate_offset(check.id, check.interval_seconds)
|
|
|
|
%{check_id: check.id}
|
|
|> CheckExecutorWorker.new(schedule_in: offset)
|
|
|> Oban.insert()
|
|
end
|
|
|
|
## Check Results
|
|
|
|
@doc """
|
|
Creates a check result.
|
|
"""
|
|
def create_check_result(attrs \\ %{}) do
|
|
%CheckResult{}
|
|
|> CheckResult.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Gets the latest check result for a check.
|
|
"""
|
|
def get_latest_check_result(check_id) do
|
|
Repo.one(from(cr in CheckResult, where: cr.check_id == ^check_id, order_by: [desc: cr.checked_at], limit: 1))
|
|
end
|
|
|
|
@doc """
|
|
Gets check results for a check within a time range.
|
|
"""
|
|
def get_check_results(check_id, opts \\ []) do
|
|
from_time = opts[:from] || DateTime.add(DateTime.utc_now(), -3600, :second)
|
|
to_time = opts[:to] || DateTime.utc_now()
|
|
limit = opts[:limit] || 1000
|
|
|
|
Repo.all(
|
|
from(cr in CheckResult,
|
|
where: cr.check_id == ^check_id,
|
|
where: cr.checked_at >= ^from_time,
|
|
where: cr.checked_at <= ^to_time,
|
|
order_by: [desc: cr.checked_at],
|
|
limit: ^limit
|
|
)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Gets graph data for a check, combining check_results and legacy sensor readings.
|
|
|
|
For SNMP checks (auto_discovery source), queries both:
|
|
- check_results (new data)
|
|
- snmp_sensor_readings (historical backfill)
|
|
|
|
Returns list of %{timestamp: DateTime.t(), value: float()} sorted by timestamp.
|
|
"""
|
|
def get_check_graph_data(check_id, from_time, to_time) do
|
|
check = get_check!(check_id)
|
|
|
|
# Query new check_results table
|
|
recent_results =
|
|
Repo.all(
|
|
from(r in CheckResult,
|
|
where: r.check_id == ^check_id,
|
|
where: r.checked_at >= ^from_time,
|
|
where: r.checked_at <= ^to_time,
|
|
where: not is_nil(r.value),
|
|
order_by: [asc: r.checked_at],
|
|
select: %{timestamp: r.checked_at, value: r.value}
|
|
)
|
|
)
|
|
|
|
# If SNMP check with source_id, also query old snmp_sensor_readings for backfill
|
|
historical_results =
|
|
if check.check_type == "snmp_sensor" && check.source_id do
|
|
alias Towerops.Snmp.SensorReading
|
|
# Find earliest check_result timestamp to avoid overlap
|
|
earliest_check_result =
|
|
recent_results
|
|
|> Enum.map(& &1.timestamp)
|
|
|> Enum.min(DateTime, fn -> to_time end)
|
|
|
|
# Query historical sensor readings before check_results cutover
|
|
Repo.all(
|
|
from(r in SensorReading,
|
|
where: r.sensor_id == ^check.source_id,
|
|
where: r.checked_at >= ^from_time,
|
|
where: r.checked_at < ^earliest_check_result,
|
|
where: not is_nil(r.value),
|
|
order_by: [asc: r.checked_at],
|
|
select: %{timestamp: r.checked_at, value: r.value}
|
|
)
|
|
)
|
|
else
|
|
[]
|
|
end
|
|
|
|
# Combine and sort by timestamp
|
|
Enum.sort_by(historical_results ++ recent_results, & &1.timestamp, DateTime)
|
|
end
|
|
|
|
@doc """
|
|
Updates check state after a check execution.
|
|
Handles soft/hard state transitions.
|
|
"""
|
|
def update_check_state(%Check{} = check, status, _output) do
|
|
now = DateTime.utc_now()
|
|
state_changed = check.current_state != status
|
|
|
|
{new_state_type, new_check_attempt} =
|
|
calculate_state_transition(check, status, state_changed)
|
|
|
|
attrs = %{
|
|
current_state: status,
|
|
current_state_type: new_state_type,
|
|
current_check_attempt: new_check_attempt,
|
|
last_check_at: now
|
|
}
|
|
|
|
attrs =
|
|
if state_changed do
|
|
Map.put(attrs, :last_state_change_at, now)
|
|
else
|
|
attrs
|
|
end
|
|
|
|
attrs =
|
|
if new_state_type == "hard" and state_changed do
|
|
Map.put(attrs, :last_hard_state_change_at, now)
|
|
else
|
|
attrs
|
|
end
|
|
|
|
update_check(check, attrs)
|
|
end
|
|
|
|
defp calculate_state_transition(check, _new_status, true = _state_changed) do
|
|
new_attempt = check.current_check_attempt + 1
|
|
state_type = if new_attempt >= check.max_check_attempts, do: "hard", else: "soft"
|
|
{state_type, new_attempt}
|
|
end
|
|
|
|
defp calculate_state_transition(%{current_state_type: "hard"} = check, _new_status, false) do
|
|
{"hard", check.current_check_attempt}
|
|
end
|
|
|
|
defp calculate_state_transition(check, _new_status, false) do
|
|
new_attempt = min(check.current_check_attempt + 1, check.max_check_attempts)
|
|
state_type = if new_attempt >= check.max_check_attempts, do: "hard", else: "soft"
|
|
{state_type, new_attempt}
|
|
end
|
|
|
|
@doc """
|
|
Stops all check jobs for a device by canceling scheduled Oban jobs.
|
|
|
|
Used when deleting a device to clean up scheduled check executions.
|
|
"""
|
|
def stop_device_checks(device_id) do
|
|
# Get all check IDs for this device
|
|
check_ids =
|
|
Repo.all(
|
|
from c in Check,
|
|
where: c.device_id == ^device_id,
|
|
select: c.id
|
|
)
|
|
|
|
# Cancel all scheduled jobs for these checks
|
|
Enum.each(check_ids, fn check_id ->
|
|
Oban.cancel_all_jobs(
|
|
from(j in Oban.Job,
|
|
where: j.queue == "check_executors",
|
|
where: j.state in ["available", "scheduled", "retryable"],
|
|
where: fragment("? @> ?", j.args, ^%{"check_id" => check_id})
|
|
)
|
|
)
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
@doc """
|
|
Disables all checks for a device.
|
|
|
|
Used when SNMP is disabled on a device to stop polling without deleting checks.
|
|
Checks can be re-enabled when SNMP is re-enabled.
|
|
"""
|
|
def disable_device_checks(device_id) do
|
|
Repo.update_all(from(c in Check, where: c.device_id == ^device_id, where: c.enabled == true),
|
|
set: [enabled: false, updated_at: DateTime.utc_now()]
|
|
)
|
|
|
|
# Also cancel any scheduled jobs
|
|
stop_device_checks(device_id)
|
|
|
|
:ok
|
|
end
|
|
|
|
## Monitoring Checks (ping results from agents)
|
|
|
|
@doc """
|
|
Creates a monitoring check record in the monitoring_checks table.
|
|
"""
|
|
def create_monitoring_check(attrs \\ %{}) do
|
|
%MonitoringCheck{}
|
|
|> MonitoringCheck.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Returns a health summary for multiple devices in a single batch query.
|
|
|
|
Queries the `checks` table to find the worst-case `current_state` and
|
|
latest response time across all enabled checks for each device.
|
|
|
|
Returns `%{device_id => %{worst_status: 0..3, latest_response_ms: float | nil, last_check_at: DateTime.t() | nil}}`.
|
|
Devices with no checks will not appear in the map.
|
|
"""
|
|
def get_device_health_summary(device_ids) when is_list(device_ids) do
|
|
if device_ids == [] do
|
|
%{}
|
|
else
|
|
Check
|
|
|> where([c], c.device_id in ^device_ids and c.enabled == true)
|
|
|> group_by([c], c.device_id)
|
|
|> select([c], %{
|
|
device_id: c.device_id,
|
|
worst_status: max(c.current_state),
|
|
last_check_at: max(c.last_check_at)
|
|
})
|
|
|> Repo.all()
|
|
|> Map.new(fn row -> {row.device_id, row} end)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns latest response times for multiple devices from monitoring_checks.
|
|
|
|
Fetches the most recent monitoring_check per device.
|
|
Returns `%{device_id => %{response_time_ms: float | nil, checked_at: DateTime.t()}}`.
|
|
"""
|
|
def get_device_latest_response_times(device_ids) when is_list(device_ids) do
|
|
if device_ids == [] do
|
|
%{}
|
|
else
|
|
# Use a lateral join / DISTINCT ON to get latest per device
|
|
MonitoringCheck
|
|
|> where([mc], mc.device_id in ^device_ids)
|
|
|> distinct([mc], [mc.device_id])
|
|
|> order_by([mc], [mc.device_id, desc: mc.checked_at])
|
|
|> select([mc], %{
|
|
device_id: mc.device_id,
|
|
response_time_ms: mc.response_time_ms,
|
|
checked_at: mc.checked_at
|
|
})
|
|
|> Repo.all()
|
|
|> Map.new(fn row -> {row.device_id, row} end)
|
|
end
|
|
end
|
|
|
|
## Legacy ping-based monitoring compatibility (stub)
|
|
|
|
@doc false
|
|
def get_latency_data(_device_or_site_id, _opts), do: []
|
|
end
|