add service checks REST API, GraphQL API, and ping executor (#52)
- REST CRUD at /api/v1/checks for HTTP, TCP, DNS, ping checks - GraphQL queries (checks, check) and mutations (create/update/delete) - ping executor using system ping with macOS/Linux parsing - check config validation for ping type (requires host) - API documentation updated with checks resource section Reviewed-on: graham/towerops-web#52
This commit is contained in:
parent
d0e2ead923
commit
28e97ff5f0
14 changed files with 1823 additions and 1 deletions
|
|
@ -1,3 +1,21 @@
|
|||
2026-03-16
|
||||
feat: add service checks REST API, GraphQL API, and ping executor
|
||||
- New REST API: full CRUD at /api/v1/checks for HTTP, TCP, DNS, and ping checks
|
||||
- New GraphQL API: checks/check queries and createCheck/updateCheck/deleteCheck mutations
|
||||
- New ping executor: replaces stub with real ICMP ping via system ping command
|
||||
- Ping executor parses both macOS and Linux output for packet loss and RTT
|
||||
- Check config validation added for ping type (requires "host")
|
||||
- API docs updated with Checks resource section
|
||||
Files: lib/towerops/monitoring/executors/ping_executor.ex (new),
|
||||
lib/towerops/workers/check_executor_worker.ex,
|
||||
lib/towerops/monitoring/check.ex,
|
||||
lib/towerops_web/controllers/api/v1/checks_controller.ex (new),
|
||||
lib/towerops_web/router.ex,
|
||||
lib/towerops_web/graphql/types/check.ex (new),
|
||||
lib/towerops_web/graphql/resolvers/check.ex (new),
|
||||
lib/towerops_web/graphql/schema.ex,
|
||||
lib/towerops_web/controllers/api_docs_html/index.html.heex
|
||||
|
||||
2026-03-14
|
||||
fix: network map label size, site layout, and missing links
|
||||
- Increased node label font from 11px to 13px and compound node label to 14px
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ defmodule Towerops.Monitoring.Check do
|
|||
"http" -> validate_http_config(changeset, config)
|
||||
"tcp" -> validate_tcp_config(changeset, config)
|
||||
"dns" -> validate_dns_config(changeset, config)
|
||||
"ping" -> validate_ping_config(changeset, config)
|
||||
_ -> changeset
|
||||
end
|
||||
end
|
||||
|
|
@ -152,6 +153,14 @@ defmodule Towerops.Monitoring.Check do
|
|||
end
|
||||
end
|
||||
|
||||
defp validate_ping_config(changeset, config) do
|
||||
if Map.has_key?(config, "host") do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :config, "Ping check requires 'host' in config")
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_source_type(changeset) do
|
||||
source_type = get_field(changeset, :source_type)
|
||||
|
||||
|
|
|
|||
125
lib/towerops/monitoring/executors/ping_executor.ex
Normal file
125
lib/towerops/monitoring/executors/ping_executor.ex
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
defmodule Towerops.Monitoring.Executors.PingExecutor do
|
||||
@moduledoc """
|
||||
Executes ICMP ping checks using the system `ping` command.
|
||||
|
||||
Config format:
|
||||
%{
|
||||
"host" => "10.0.0.1",
|
||||
"count" => 3 # optional, default: 3
|
||||
}
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@default_count 3
|
||||
@default_timeout 5000
|
||||
|
||||
@doc """
|
||||
Executes a ping check.
|
||||
|
||||
Returns:
|
||||
- {:ok, response_time_ms, output} on success
|
||||
- {:error, reason} on failure
|
||||
"""
|
||||
def execute(config, timeout_ms \\ @default_timeout) do
|
||||
host = Map.fetch!(config, "host")
|
||||
count = config |> Map.get("count", @default_count) |> max(1) |> min(10)
|
||||
|
||||
with :ok <- validate_host(host) do
|
||||
args = build_args(host, count, timeout_ms)
|
||||
|
||||
# Use timeout_ms plus buffer for the system call
|
||||
system_timeout = timeout_ms + count * 1000 + 2000
|
||||
|
||||
case System.cmd("ping", args, stderr_to_stdout: true, timeout: system_timeout) do
|
||||
{output, 0} ->
|
||||
parse_output(output)
|
||||
|
||||
{output, _exit_code} ->
|
||||
# Non-zero exit — try to parse anyway (partial loss still has stats)
|
||||
case parse_output(output) do
|
||||
{:ok, _, _} = success -> success
|
||||
{:error, _} = error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Ping check exception: #{inspect(e)}")
|
||||
{:error, "Exception: #{Exception.message(e)}"}
|
||||
catch
|
||||
:exit, {:timeout, _} ->
|
||||
{:error, "Ping command timed out"}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses ping command output to extract RTT and packet loss.
|
||||
"""
|
||||
def parse_output(output) when is_binary(output) and byte_size(output) > 0 do
|
||||
with {:ok, loss_pct, packets_info} <- parse_packet_loss(output),
|
||||
{:ok, avg_ms} <- parse_rtt(output) do
|
||||
if loss_pct >= 100.0 do
|
||||
{:error, "100% packet loss"}
|
||||
else
|
||||
summary = "#{packets_info}, #{format_loss(loss_pct)} loss, avg #{avg_ms}ms"
|
||||
{:ok, avg_ms, summary}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def parse_output(_), do: {:error, "No ping output"}
|
||||
|
||||
defp validate_host(host) do
|
||||
# Only allow valid hostnames and IP addresses — no shell metacharacters
|
||||
if Regex.match?(~r/^[a-zA-Z0-9\.\-\:]+$/, host) do
|
||||
:ok
|
||||
else
|
||||
{:error, "Invalid host: contains disallowed characters"}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_args(host, count, timeout_ms) do
|
||||
count_str = Integer.to_string(count)
|
||||
|
||||
case :os.type() do
|
||||
{:unix, :darwin} ->
|
||||
# macOS: -W is in milliseconds
|
||||
["-c", count_str, "-W", Integer.to_string(timeout_ms), host]
|
||||
|
||||
{:unix, _} ->
|
||||
# Linux: -w is in seconds (total deadline)
|
||||
deadline = max(div(timeout_ms, 1000), count + 1)
|
||||
["-c", count_str, "-w", Integer.to_string(deadline), host]
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_packet_loss(output) do
|
||||
# Match patterns like "3 packets transmitted, 3 packets received, 0.0% packet loss"
|
||||
# or "3 packets transmitted, 3 received, 0% packet loss"
|
||||
case Regex.run(~r/(\d+) packets? transmitted, (\d+) (?:packets? )?received, ([\d.]+)% packet loss/, output) do
|
||||
[_, transmitted, _received, loss_str] ->
|
||||
{loss_pct, _} = Float.parse(loss_str)
|
||||
{:ok, loss_pct, "#{transmitted} packets"}
|
||||
|
||||
nil ->
|
||||
{:error, "Could not parse packet loss from ping output"}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_rtt(output) do
|
||||
# macOS: "round-trip min/avg/max/stddev = 0.042/0.059/0.068/0.012 ms"
|
||||
# Linux: "rtt min/avg/max/mdev = 1.120/1.266/1.450/0.140 ms"
|
||||
case Regex.run(~r/(?:round-trip|rtt) min\/avg\/max\/(?:std|m)dev = [\d.]+\/([\d.]+)\//, output) do
|
||||
[_, avg_str] ->
|
||||
{avg_ms, _} = Float.parse(avg_str)
|
||||
{:ok, avg_ms}
|
||||
|
||||
nil ->
|
||||
# No RTT stats (100% loss case)
|
||||
{:error, "100% packet loss"}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_loss(loss_pct) when loss_pct == 0.0, do: "0%"
|
||||
defp format_loss(loss_pct), do: "#{Float.round(loss_pct, 1)}%"
|
||||
end
|
||||
|
|
@ -28,6 +28,7 @@ defmodule Towerops.Workers.CheckExecutorWorker do
|
|||
alias Towerops.Monitoring
|
||||
alias Towerops.Monitoring.Executors.DnsExecutor
|
||||
alias Towerops.Monitoring.Executors.HttpExecutor
|
||||
alias Towerops.Monitoring.Executors.PingExecutor
|
||||
alias Towerops.Monitoring.Executors.SnmpInterfaceExecutor
|
||||
alias Towerops.Monitoring.Executors.SnmpProcessorExecutor
|
||||
alias Towerops.Monitoring.Executors.SnmpSensorExecutor
|
||||
|
|
@ -86,7 +87,7 @@ defmodule Towerops.Workers.CheckExecutorWorker do
|
|||
defp dispatch_executor(%{check_type: "http"} = check), do: execute_http_check(check)
|
||||
defp dispatch_executor(%{check_type: "tcp"} = check), do: execute_tcp_check(check)
|
||||
defp dispatch_executor(%{check_type: "dns"} = check), do: execute_dns_check(check)
|
||||
defp dispatch_executor(%{check_type: "ping"}), do: {:error, "Ping executor not yet implemented"}
|
||||
defp dispatch_executor(%{check_type: "ping"} = check), do: execute_ping_check(check)
|
||||
defp dispatch_executor(%{check_type: type}), do: {:error, "Unknown check type: #{type}"}
|
||||
|
||||
defp record_result({:ok, %{value: value, status: status, output: output, response_time_ms: time}}, check) do
|
||||
|
|
@ -171,6 +172,23 @@ defmodule Towerops.Workers.CheckExecutorWorker do
|
|||
end
|
||||
end
|
||||
|
||||
# Adapter for Ping executor which returns different format
|
||||
defp execute_ping_check(check) do
|
||||
case PingExecutor.execute(check.config, check.timeout_ms) do
|
||||
{:ok, response_time, output} ->
|
||||
{:ok,
|
||||
%{
|
||||
value: nil,
|
||||
status: 0,
|
||||
output: output,
|
||||
response_time_ms: response_time
|
||||
}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp schedule_next_check(check) do
|
||||
# Calculate staggered offset based on check ID
|
||||
offset = PollingOffset.calculate_offset(check.id, check.interval_seconds)
|
||||
|
|
|
|||
199
lib/towerops_web/controllers/api/v1/checks_controller.ex
Normal file
199
lib/towerops_web/controllers/api/v1/checks_controller.ex
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
defmodule ToweropsWeb.Api.V1.ChecksController do
|
||||
@moduledoc """
|
||||
API controller for managing service checks (HTTP, TCP, DNS, ping).
|
||||
|
||||
All endpoints require API token authentication and operations are scoped
|
||||
to the organization associated with the token. Only service check types
|
||||
(http, tcp, dns, ping) are exposed — SNMP checks are managed internally.
|
||||
"""
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
|
||||
|
||||
alias Towerops.Monitoring
|
||||
alias Towerops.Monitoring.Check
|
||||
alias ToweropsWeb.ScopedResource
|
||||
|
||||
@service_check_types ~w(http tcp dns ping)
|
||||
|
||||
@doc """
|
||||
GET /api/v1/checks
|
||||
|
||||
Lists service checks for the authenticated organization.
|
||||
Optional query params: `check_type`, `device_id`.
|
||||
"""
|
||||
def index(conn, params) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
opts =
|
||||
[]
|
||||
|> maybe_add_opt(:device_id, params["device_id"])
|
||||
|> maybe_add_opt(:check_type, params["check_type"])
|
||||
|
||||
checks =
|
||||
organization_id
|
||||
|> Monitoring.list_checks(opts)
|
||||
|> Enum.filter(&(&1.check_type in @service_check_types))
|
||||
|> Enum.map(&format_check/1)
|
||||
|
||||
json(conn, %{checks: checks})
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/v1/checks
|
||||
|
||||
Creates a new service check for the authenticated organization.
|
||||
"""
|
||||
def create(conn, %{"check" => check_params}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
check_type = Map.get(check_params, "check_type", "")
|
||||
|
||||
if check_type in @service_check_types do
|
||||
attrs = Map.put(check_params, "organization_id", organization_id)
|
||||
|
||||
case Monitoring.create_check(attrs) do
|
||||
{:ok, check} ->
|
||||
if check.enabled, do: Monitoring.schedule_check(check)
|
||||
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(format_check(check))
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: translate_errors(changeset)})
|
||||
end
|
||||
else
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Invalid check type '#{check_type}'. Must be one of: #{Enum.join(@service_check_types, ", ")}"})
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing 'check' parameter"})
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/v1/checks/:id
|
||||
|
||||
Gets a single check by ID.
|
||||
"""
|
||||
def show(conn, %{"id" => id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case ScopedResource.fetch(Check, id, organization_id) do
|
||||
{:ok, check} ->
|
||||
json(conn, format_check(check))
|
||||
|
||||
{:error, :forbidden} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Access denied to this check"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Check not found"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
PATCH /api/v1/checks/:id
|
||||
|
||||
Updates a check.
|
||||
"""
|
||||
def update(conn, %{"id" => id, "check" => check_params}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case ScopedResource.fetch(Check, id, organization_id) do
|
||||
{:ok, check} ->
|
||||
case Monitoring.update_check(check, check_params) do
|
||||
{:ok, updated_check} ->
|
||||
json(conn, format_check(updated_check))
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: translate_errors(changeset)})
|
||||
end
|
||||
|
||||
{:error, :forbidden} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Access denied to this check"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Check not found"})
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing 'check' parameter"})
|
||||
end
|
||||
|
||||
@doc """
|
||||
DELETE /api/v1/checks/:id
|
||||
|
||||
Deletes a check and cancels any scheduled jobs.
|
||||
"""
|
||||
def delete(conn, %{"id" => id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case ScopedResource.fetch(Check, id, organization_id) do
|
||||
{:ok, check} ->
|
||||
case Monitoring.delete_check(check) do
|
||||
{:ok, _check} ->
|
||||
json(conn, %{success: true})
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: translate_errors(changeset)})
|
||||
end
|
||||
|
||||
{:error, :forbidden} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Access denied to this check"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Check not found"})
|
||||
end
|
||||
end
|
||||
|
||||
defp format_check(check) do
|
||||
%{
|
||||
id: check.id,
|
||||
name: check.name,
|
||||
check_type: check.check_type,
|
||||
description: check.description,
|
||||
enabled: check.enabled,
|
||||
device_id: check.device_id,
|
||||
agent_token_id: check.agent_token_id,
|
||||
interval_seconds: check.interval_seconds,
|
||||
timeout_ms: check.timeout_ms,
|
||||
retry_interval_seconds: check.retry_interval_seconds,
|
||||
max_check_attempts: check.max_check_attempts,
|
||||
config: check.config,
|
||||
current_state: check.current_state,
|
||||
current_state_type: check.current_state_type,
|
||||
last_check_at: check.last_check_at,
|
||||
inserted_at: check.inserted_at
|
||||
}
|
||||
end
|
||||
|
||||
defp maybe_add_opt(opts, _key, nil), do: opts
|
||||
defp maybe_add_opt(opts, _key, ""), do: opts
|
||||
defp maybe_add_opt(opts, key, value), do: Keyword.put(opts, key, value)
|
||||
end
|
||||
|
|
@ -130,6 +130,15 @@
|
|||
Integrations
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#checks"
|
||||
data-nav-link="checks"
|
||||
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
Checks
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#check-results"
|
||||
|
|
@ -3100,6 +3109,445 @@ curl -X POST https://towerops.net/api/v1/integrations/intg-uuid-1/test \\
|
|||
|
||||
<hr class="my-16 border-gray-200 dark:border-white/10" />
|
||||
|
||||
<!-- Checks Resource -->
|
||||
<section id="checks" class="scroll-mt-24 mb-16">
|
||||
<h2 class="text-3xl font-bold tracking-tight text-zinc-900 dark:text-white">
|
||||
Checks
|
||||
</h2>
|
||||
<p class="mt-4 text-base text-gray-600 dark:text-gray-400">
|
||||
Service checks monitor the availability and responsiveness of network services. Supported check types: HTTP, TCP, DNS, and ping. SNMP checks are managed internally and are not exposed via this API.
|
||||
</p>
|
||||
|
||||
<!-- Check Model -->
|
||||
<div class="mt-12">
|
||||
<h3 class="text-xl font-semibold text-zinc-900 dark:text-white">The check model</h3>
|
||||
<div class="mt-6">
|
||||
<dl class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<div class="py-4">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
id
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
string
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Unique identifier for the check (UUID).
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
name
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
string
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
The name of the check.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
check_type
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
string
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
The type of check: <code>http</code>, <code>tcp</code>, <code>dns</code>, or <code>ping</code>.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
enabled
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
boolean
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Whether the check is enabled and scheduled to run.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
config
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
object
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Type-specific configuration. HTTP: <code>url</code>, <code>method</code>, <code>expected_status</code>, <code>verify_ssl</code>, <code>follow_redirects</code>, <code>regex</code>. TCP: <code>host</code>, <code>port</code>, <code>send</code>, <code>expect</code>. DNS: <code>hostname</code>, <code>record_type</code>, <code>server</code>, <code>expected</code>. Ping: <code>host</code>, <code>count</code>.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
interval_seconds
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
integer
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
How often the check runs, in seconds. Default: 60.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
current_state
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
integer
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Current check state: 0=OK, 1=WARNING, 2=CRITICAL, 3=UNKNOWN.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
current_state_type
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
string
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Current state type: <code>soft</code> or <code>hard</code>.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-12 border-gray-200 dark:border-white/10" />
|
||||
|
||||
<!-- List all checks -->
|
||||
<div class="mt-12">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<h3 class="text-xl font-semibold text-zinc-900 dark:text-white">List all checks</h3>
|
||||
<span class="inline-flex items-center rounded-md bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-400/10 dark:text-emerald-400 dark:ring-emerald-400/30">
|
||||
GET
|
||||
</span>
|
||||
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">/api/v1/checks</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-x-12 gap-y-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Lists all service checks for the authenticated organization. Only returns HTTP, TCP, DNS, and ping checks.
|
||||
</p>
|
||||
|
||||
<h4 class="mt-6 text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
Optional query parameters
|
||||
</h4>
|
||||
<dl class="mt-3 divide-y divide-gray-200 dark:divide-white/10">
|
||||
<div class="py-3">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
check_type
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
string
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Filter by check type: <code>http</code>, <code>tcp</code>, <code>dns</code>, or <code>ping</code>.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
device_id
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
string
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Filter checks by device UUID.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
|
||||
<div class="border-b border-zinc-800 px-4 py-2">
|
||||
<p class="text-xs font-medium text-zinc-400">Request</p>
|
||||
</div>
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
curl -G https://towerops.net/api/v1/checks \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-d check_type=http
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
|
||||
<div class="border-b border-zinc-800 px-4 py-2">
|
||||
<p class="text-xs font-medium text-zinc-400">Response</p>
|
||||
</div>
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
|
||||
"name": "Web Health Check",
|
||||
"check_type": "http",
|
||||
"enabled": true,
|
||||
"config": {"url": "https://example.com/health", "expected_status": 200},
|
||||
"interval_seconds": 60,
|
||||
"current_state": 0,
|
||||
"current_state_type": "hard",
|
||||
"last_check_at": "2026-03-16T12:00:00Z",
|
||||
"inserted_at": "2026-03-15T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-12 border-gray-200 dark:border-white/10" />
|
||||
|
||||
<!-- Create a check -->
|
||||
<div class="mt-12">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<h3 class="text-xl font-semibold text-zinc-900 dark:text-white">Create a check</h3>
|
||||
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/20 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30">
|
||||
POST
|
||||
</span>
|
||||
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">/api/v1/checks</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-x-12 gap-y-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Creates a new service check for the authenticated organization. The check is automatically scheduled if enabled.
|
||||
</p>
|
||||
|
||||
<h4 class="mt-6 text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
Required parameters
|
||||
</h4>
|
||||
<dl class="mt-3 divide-y divide-gray-200 dark:divide-white/10">
|
||||
<div class="py-3">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
name
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
string
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
The name of the check.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
check_type
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
string
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
One of: <code>http</code>, <code>tcp</code>, <code>dns</code>, <code>ping</code>.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
config
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
object
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Type-specific configuration object. See check model for available fields.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<h4 class="mt-6 text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
Optional parameters
|
||||
</h4>
|
||||
<dl class="mt-3 divide-y divide-gray-200 dark:divide-white/10">
|
||||
<div class="py-3">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
enabled
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
boolean
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Whether the check is enabled. Default: <code>true</code>.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
device_id
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
string
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Associate the check with a device.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<dt class="text-sm font-mono font-medium text-zinc-900 dark:text-white">
|
||||
interval_seconds
|
||||
<span class="ml-2 font-sans text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
integer
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
How often the check runs. Default: <code>60</code>.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
|
||||
<div class="border-b border-zinc-800 px-4 py-2">
|
||||
<p class="text-xs font-medium text-zinc-400">Request</p>
|
||||
</div>
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
curl https://towerops.net/api/v1/checks \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"check": {"name": "Web Health", "check_type": "http", "config": {"url": "https://example.com/health", "expected_status": 200}}}'
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
|
||||
<div class="border-b border-zinc-800 px-4 py-2">
|
||||
<p class="text-xs font-medium text-zinc-400">Response <span class="text-zinc-500">201 Created</span></p>
|
||||
</div>
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
{
|
||||
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
|
||||
"name": "Web Health",
|
||||
"check_type": "http",
|
||||
"enabled": true,
|
||||
"config": {"url": "https://example.com/health", "expected_status": 200},
|
||||
"interval_seconds": 60,
|
||||
"timeout_ms": 5000,
|
||||
"current_state": null,
|
||||
"inserted_at": "2026-03-16T10:00:00Z"
|
||||
}
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-12 border-gray-200 dark:border-white/10" />
|
||||
|
||||
<!-- Get a check -->
|
||||
<div class="mt-12">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<h3 class="text-xl font-semibold text-zinc-900 dark:text-white">Retrieve a check</h3>
|
||||
<span class="inline-flex items-center rounded-md bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-400/10 dark:text-emerald-400 dark:ring-emerald-400/30">
|
||||
GET
|
||||
</span>
|
||||
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">/api/v1/checks/:id</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-x-12 gap-y-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Retrieves a single check by its UUID.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
|
||||
<div class="border-b border-zinc-800 px-4 py-2">
|
||||
<p class="text-xs font-medium text-zinc-400">Request</p>
|
||||
</div>
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
curl https://towerops.net/api/v1/checks/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
|
||||
-H "Authorization: Bearer {token}"
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-12 border-gray-200 dark:border-white/10" />
|
||||
|
||||
<!-- Update a check -->
|
||||
<div class="mt-12">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<h3 class="text-xl font-semibold text-zinc-900 dark:text-white">Update a check</h3>
|
||||
<span class="inline-flex items-center rounded-md bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/30">
|
||||
PATCH
|
||||
</span>
|
||||
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">/api/v1/checks/:id</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-x-12 gap-y-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Updates an existing check. Only the provided fields will be updated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
|
||||
<div class="border-b border-zinc-800 px-4 py-2">
|
||||
<p class="text-xs font-medium text-zinc-400">Request</p>
|
||||
</div>
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
curl -X PATCH https://towerops.net/api/v1/checks/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"check": {"name": "Updated Name", "interval_seconds": 120}}'
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-12 border-gray-200 dark:border-white/10" />
|
||||
|
||||
<!-- Delete a check -->
|
||||
<div class="mt-12">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<h3 class="text-xl font-semibold text-zinc-900 dark:text-white">Delete a check</h3>
|
||||
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/20 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/30">
|
||||
DELETE
|
||||
</span>
|
||||
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">/api/v1/checks/:id</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-x-12 gap-y-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Deletes a check and cancels any scheduled executions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
|
||||
<div class="border-b border-zinc-800 px-4 py-2">
|
||||
<p class="text-xs font-medium text-zinc-400">Request</p>
|
||||
</div>
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
curl -X DELETE https://towerops.net/api/v1/checks/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
|
||||
-H "Authorization: Bearer {token}"
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
|
||||
<div class="border-b border-zinc-800 px-4 py-2">
|
||||
<p class="text-xs font-medium text-zinc-400">Response</p>
|
||||
</div>
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="my-16 border-gray-200 dark:border-white/10" />
|
||||
|
||||
<!-- Check Results & Metrics Resource -->
|
||||
<section id="check-results" class="scroll-mt-24 mb-16">
|
||||
<h2 class="text-3xl font-bold tracking-tight text-zinc-900 dark:text-white">
|
||||
|
|
|
|||
90
lib/towerops_web/graphql/resolvers/check.ex
Normal file
90
lib/towerops_web/graphql/resolvers/check.ex
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
defmodule ToweropsWeb.GraphQL.Resolvers.Check do
|
||||
@moduledoc "GraphQL resolvers for service check queries and mutations."
|
||||
|
||||
alias Towerops.Monitoring
|
||||
alias Towerops.Monitoring.Check
|
||||
alias ToweropsWeb.GraphQL.Resolvers.Helpers
|
||||
alias ToweropsWeb.ScopedResource
|
||||
|
||||
@service_check_types ~w(http tcp dns ping)
|
||||
|
||||
def list(_parent, args, %{context: %{organization_id: org_id}}) do
|
||||
opts =
|
||||
[]
|
||||
|> maybe_add_opt(:device_id, args[:device_id])
|
||||
|> maybe_add_opt(:check_type, args[:check_type])
|
||||
|
||||
checks =
|
||||
org_id
|
||||
|> Monitoring.list_checks(opts)
|
||||
|> Enum.filter(&(&1.check_type in @service_check_types))
|
||||
|
||||
{:ok, checks}
|
||||
end
|
||||
|
||||
def list(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
|
||||
fetch_org_check(id, org_id)
|
||||
end
|
||||
|
||||
def get(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def create(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do
|
||||
attrs =
|
||||
input
|
||||
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|
||||
|> Map.put("organization_id", org_id)
|
||||
|
||||
check_type = Map.get(attrs, "check_type", "")
|
||||
|
||||
if check_type in @service_check_types do
|
||||
case Monitoring.create_check(attrs) do
|
||||
{:ok, check} ->
|
||||
if check.enabled, do: Monitoring.schedule_check(check)
|
||||
{:ok, check}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
else
|
||||
{:error, "Invalid check type '#{check_type}'. Must be one of: #{Enum.join(@service_check_types, ", ")}"}
|
||||
end
|
||||
end
|
||||
|
||||
def create(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def update(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, check} <- fetch_org_check(id, org_id) do
|
||||
attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end)
|
||||
|
||||
case Monitoring.update_check(check, attrs) do
|
||||
{:ok, updated} -> {:ok, updated}
|
||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
|
||||
with {:ok, check} <- fetch_org_check(id, org_id) do
|
||||
case Monitoring.delete_check(check) do
|
||||
{:ok, _} -> {:ok, %{success: true, message: "Check deleted"}}
|
||||
{:error, _} -> {:ok, %{success: false, message: "Could not delete check"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||
|
||||
defp fetch_org_check(id, org_id) do
|
||||
case ScopedResource.fetch(Check, id, org_id) do
|
||||
{:ok, check} -> {:ok, check}
|
||||
{:error, _reason} -> {:error, "Check not found"}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_opt(opts, _key, nil), do: opts
|
||||
defp maybe_add_opt(opts, key, value), do: Keyword.put(opts, key, value)
|
||||
end
|
||||
|
|
@ -24,6 +24,7 @@ defmodule ToweropsWeb.GraphQL.Schema do
|
|||
import_types(ToweropsWeb.GraphQL.Types.Schedule)
|
||||
import_types(ToweropsWeb.GraphQL.Types.EscalationPolicy)
|
||||
import_types(ToweropsWeb.GraphQL.Types.MaintenanceWindow)
|
||||
import_types(ToweropsWeb.GraphQL.Types.Check)
|
||||
import_types(ToweropsWeb.GraphQL.Types.TimeSeries)
|
||||
|
||||
query do
|
||||
|
|
@ -80,6 +81,22 @@ defmodule ToweropsWeb.GraphQL.Schema do
|
|||
resolve(&ToweropsWeb.GraphQL.Resolvers.Alert.get/3)
|
||||
end
|
||||
|
||||
# Service Checks
|
||||
field :checks, list_of(:check) do
|
||||
arg(:organization_id, :id)
|
||||
arg(:device_id, :id)
|
||||
arg(:check_type, :string)
|
||||
middleware(OrganizationScope)
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Check.list/3)
|
||||
end
|
||||
|
||||
field :check, :check do
|
||||
arg(:organization_id, :id)
|
||||
arg(:id, non_null(:id))
|
||||
middleware(OrganizationScope)
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Check.get/3)
|
||||
end
|
||||
|
||||
# Agents
|
||||
field :agents, list_of(:agent) do
|
||||
arg(:organization_id, :id)
|
||||
|
|
@ -260,6 +277,23 @@ defmodule ToweropsWeb.GraphQL.Schema do
|
|||
resolve(&ToweropsWeb.GraphQL.Resolvers.Site.delete/3)
|
||||
end
|
||||
|
||||
# Check CRUD
|
||||
field :create_check, :check do
|
||||
arg(:input, non_null(:check_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Check.create/3)
|
||||
end
|
||||
|
||||
field :update_check, :check do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:input, non_null(:check_input))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Check.update/3)
|
||||
end
|
||||
|
||||
field :delete_check, :delete_result do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&ToweropsWeb.GraphQL.Resolvers.Check.delete/3)
|
||||
end
|
||||
|
||||
# Alert actions
|
||||
field :acknowledge_alert, :alert do
|
||||
arg(:id, non_null(:id))
|
||||
|
|
|
|||
37
lib/towerops_web/graphql/types/check.ex
Normal file
37
lib/towerops_web/graphql/types/check.ex
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
defmodule ToweropsWeb.GraphQL.Types.Check do
|
||||
@moduledoc "GraphQL types for service checks."
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
object :check do
|
||||
field :id, :id
|
||||
field :name, :string
|
||||
field :check_type, :string
|
||||
field :description, :string
|
||||
field :enabled, :boolean
|
||||
field :device_id, :id
|
||||
field :agent_token_id, :id
|
||||
field :interval_seconds, :integer
|
||||
field :timeout_ms, :integer
|
||||
field :retry_interval_seconds, :integer
|
||||
field :max_check_attempts, :integer
|
||||
field :config, :json
|
||||
field :current_state, :integer
|
||||
field :current_state_type, :string
|
||||
field :last_check_at, :string
|
||||
field :inserted_at, :string
|
||||
end
|
||||
|
||||
input_object :check_input do
|
||||
field :name, :string
|
||||
field :check_type, :string
|
||||
field :description, :string
|
||||
field :enabled, :boolean
|
||||
field :device_id, :id
|
||||
field :agent_token_id, :id
|
||||
field :interval_seconds, :integer
|
||||
field :timeout_ms, :integer
|
||||
field :retry_interval_seconds, :integer
|
||||
field :max_check_attempts, :integer
|
||||
field :config, :json
|
||||
end
|
||||
end
|
||||
|
|
@ -156,6 +156,7 @@ defmodule ToweropsWeb.Router do
|
|||
|
||||
resources "/sites", SitesController, except: [:new, :edit]
|
||||
resources "/devices", DevicesController, except: [:new, :edit]
|
||||
resources "/checks", ChecksController, except: [:new, :edit]
|
||||
|
||||
resources "/agents", AgentsController, except: [:new, :edit]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
2026-03-16 — Service Checks API
|
||||
* New REST API for managing service checks (HTTP, TCP, DNS, ping)
|
||||
* New GraphQL queries and mutations for service check management
|
||||
* Ping monitoring now fully operational with packet loss and RTT tracking
|
||||
* API documentation updated with service checks endpoints
|
||||
|
||||
2026-03-14 — Network Map Improvements
|
||||
* Node labels are now larger and easier to read
|
||||
* Sites no longer overlap — each site group is laid out in its own section
|
||||
|
|
|
|||
137
test/towerops/monitoring/executors/ping_executor_test.exs
Normal file
137
test/towerops/monitoring/executors/ping_executor_test.exs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
defmodule Towerops.Monitoring.Executors.PingExecutorTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.Monitoring.Executors.PingExecutor
|
||||
|
||||
describe "execute/2" do
|
||||
test "returns success with valid ping output" do
|
||||
config = %{"host" => "127.0.0.1", "count" => 1}
|
||||
|
||||
case PingExecutor.execute(config, 10_000) do
|
||||
{:ok, response_time, output} ->
|
||||
assert is_number(response_time)
|
||||
assert response_time >= 0
|
||||
assert is_binary(output)
|
||||
assert output =~ "packet"
|
||||
|
||||
{:error, reason} ->
|
||||
# Ping to localhost might fail in some CI environments
|
||||
assert is_binary(reason)
|
||||
end
|
||||
end
|
||||
|
||||
test "defaults count to 3 when not provided" do
|
||||
config = %{"host" => "127.0.0.1"}
|
||||
|
||||
# Should use default count of 3 — just verify it doesn't crash
|
||||
result = PingExecutor.execute(config, 10_000)
|
||||
assert match?({:ok, _, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
test "returns error for unreachable host" do
|
||||
# RFC 5737 TEST-NET address — should be unreachable
|
||||
config = %{"host" => "192.0.2.1", "count" => 1}
|
||||
|
||||
case PingExecutor.execute(config, 6000) do
|
||||
{:error, reason} ->
|
||||
assert is_binary(reason)
|
||||
|
||||
{:ok, _time, output} ->
|
||||
# Some networks may route this, that's fine
|
||||
assert is_binary(output)
|
||||
end
|
||||
end
|
||||
|
||||
test "returns error for invalid host" do
|
||||
config = %{"host" => "definitely-not-a-real-host-12345.invalid", "count" => 1}
|
||||
|
||||
assert {:error, reason} = PingExecutor.execute(config, 6000)
|
||||
assert is_binary(reason)
|
||||
end
|
||||
|
||||
test "sanitizes host to prevent command injection" do
|
||||
config = %{"host" => "127.0.0.1; rm -rf /", "count" => 1}
|
||||
|
||||
assert {:error, reason} = PingExecutor.execute(config, 5000)
|
||||
assert reason =~ "Invalid host"
|
||||
end
|
||||
|
||||
test "sanitizes count to positive integer" do
|
||||
config = %{"host" => "127.0.0.1", "count" => -1}
|
||||
|
||||
# Should clamp or reject negative count
|
||||
result = PingExecutor.execute(config, 10_000)
|
||||
assert match?({:ok, _, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_output/1" do
|
||||
test "parses macOS ping output" do
|
||||
output = """
|
||||
PING 127.0.0.1 (127.0.0.1): 56 data bytes
|
||||
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.042 ms
|
||||
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.068 ms
|
||||
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.066 ms
|
||||
|
||||
--- 127.0.0.1 ping statistics ---
|
||||
3 packets transmitted, 3 packets received, 0.0% packet loss
|
||||
round-trip min/avg/max/stddev = 0.042/0.059/0.068/0.012 ms
|
||||
"""
|
||||
|
||||
assert {:ok, avg_ms, summary} = PingExecutor.parse_output(output)
|
||||
assert_in_delta avg_ms, 0.059, 0.001
|
||||
assert summary =~ "3 packets"
|
||||
assert summary =~ "0% loss"
|
||||
end
|
||||
|
||||
test "parses Linux ping output" do
|
||||
output = """
|
||||
PING 10.0.0.1 (10.0.0.1) 56(84) bytes of data.
|
||||
64 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=1.23 ms
|
||||
64 bytes from 10.0.0.1: icmp_seq=2 ttl=64 time=1.45 ms
|
||||
64 bytes from 10.0.0.1: icmp_seq=3 ttl=64 time=1.12 ms
|
||||
|
||||
--- 10.0.0.1 ping statistics ---
|
||||
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
|
||||
rtt min/avg/max/mdev = 1.120/1.266/1.450/0.140 ms
|
||||
"""
|
||||
|
||||
assert {:ok, avg_ms, summary} = PingExecutor.parse_output(output)
|
||||
assert_in_delta avg_ms, 1.266, 0.001
|
||||
assert summary =~ "3 packets"
|
||||
assert summary =~ "0% loss"
|
||||
end
|
||||
|
||||
test "returns error for 100% packet loss" do
|
||||
output = """
|
||||
PING 192.0.2.1 (192.0.2.1): 56 data bytes
|
||||
|
||||
--- 192.0.2.1 ping statistics ---
|
||||
3 packets transmitted, 0 packets received, 100.0% packet loss
|
||||
"""
|
||||
|
||||
assert {:error, reason} = PingExecutor.parse_output(output)
|
||||
assert reason =~ "100"
|
||||
assert reason =~ "packet loss"
|
||||
end
|
||||
|
||||
test "returns error for partial packet loss" do
|
||||
output = """
|
||||
PING 10.0.0.1 (10.0.0.1): 56 data bytes
|
||||
64 bytes from 10.0.0.1: icmp_seq=0 ttl=64 time=1.23 ms
|
||||
|
||||
--- 10.0.0.1 ping statistics ---
|
||||
3 packets transmitted, 1 packets received, 66.7% packet loss
|
||||
round-trip min/avg/max/stddev = 1.230/1.230/1.230/0.000 ms
|
||||
"""
|
||||
|
||||
# Partial loss should still report as warning with the data
|
||||
result = PingExecutor.parse_output(output)
|
||||
assert match?({:ok, _, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
test "returns error for empty output" do
|
||||
assert {:error, _reason} = PingExecutor.parse_output("")
|
||||
end
|
||||
end
|
||||
end
|
||||
425
test/towerops_web/controllers/api/v1/checks_controller_test.exs
Normal file
425
test/towerops_web/controllers/api/v1/checks_controller_test.exs
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
defmodule ToweropsWeb.Api.V1.ChecksControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.ApiTokens
|
||||
alias Towerops.Monitoring
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
{:ok, {_token, raw_token}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
})
|
||||
|
||||
conn =
|
||||
build_conn()
|
||||
|> put_req_header("authorization", "Bearer #{raw_token}")
|
||||
|> put_req_header("accept", "application/json")
|
||||
|
||||
%{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: organization,
|
||||
raw_token: raw_token
|
||||
}
|
||||
end
|
||||
|
||||
defp create_check(organization_id, attrs \\ %{}) do
|
||||
defaults = %{
|
||||
name: "Test Check",
|
||||
check_type: "http",
|
||||
organization_id: organization_id,
|
||||
config: %{"url" => "https://example.com"}
|
||||
}
|
||||
|
||||
{:ok, check} = Monitoring.create_check(Map.merge(defaults, attrs))
|
||||
check
|
||||
end
|
||||
|
||||
describe "index/2" do
|
||||
test "lists service checks for authenticated organization", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
check1 =
|
||||
create_check(organization.id, %{name: "HTTP Check", check_type: "http", config: %{"url" => "https://example.com"}})
|
||||
|
||||
check2 =
|
||||
create_check(organization.id, %{
|
||||
name: "TCP Check",
|
||||
check_type: "tcp",
|
||||
config: %{"host" => "10.0.0.1", "port" => 80}
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/checks")
|
||||
|
||||
assert %{"checks" => checks} = json_response(conn, 200)
|
||||
assert length(checks) == 2
|
||||
|
||||
check_ids = Enum.map(checks, & &1["id"])
|
||||
assert check1.id in check_ids
|
||||
assert check2.id in check_ids
|
||||
|
||||
check_json = List.first(checks)
|
||||
assert Map.has_key?(check_json, "id")
|
||||
assert Map.has_key?(check_json, "name")
|
||||
assert Map.has_key?(check_json, "check_type")
|
||||
assert Map.has_key?(check_json, "config")
|
||||
assert Map.has_key?(check_json, "enabled")
|
||||
assert Map.has_key?(check_json, "inserted_at")
|
||||
end
|
||||
|
||||
test "excludes SNMP checks from listing", %{conn: conn, organization: organization} do
|
||||
_http =
|
||||
create_check(organization.id, %{name: "HTTP Check", check_type: "http", config: %{"url" => "https://example.com"}})
|
||||
|
||||
# Create an SNMP check — should not appear
|
||||
{:ok, _snmp} =
|
||||
Monitoring.create_check(%{
|
||||
name: "SNMP Sensor",
|
||||
check_type: "snmp_sensor",
|
||||
organization_id: organization.id,
|
||||
config: %{"oid" => "1.3.6.1.2.1.1.3.0"}
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/checks")
|
||||
|
||||
assert %{"checks" => checks} = json_response(conn, 200)
|
||||
assert length(checks) == 1
|
||||
assert List.first(checks)["check_type"] == "http"
|
||||
end
|
||||
|
||||
test "filters by check_type query param", %{conn: conn, organization: organization} do
|
||||
_http =
|
||||
create_check(organization.id, %{name: "HTTP Check", check_type: "http", config: %{"url" => "https://example.com"}})
|
||||
|
||||
_tcp =
|
||||
create_check(organization.id, %{
|
||||
name: "TCP Check",
|
||||
check_type: "tcp",
|
||||
config: %{"host" => "10.0.0.1", "port" => 80}
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/checks?check_type=tcp")
|
||||
|
||||
assert %{"checks" => checks} = json_response(conn, 200)
|
||||
assert length(checks) == 1
|
||||
assert List.first(checks)["check_type"] == "tcp"
|
||||
end
|
||||
|
||||
test "returns empty list when organization has no checks", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/checks")
|
||||
|
||||
assert %{"checks" => []} = json_response(conn, 200)
|
||||
end
|
||||
|
||||
test "does not return checks from other organizations", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
_other_check = create_check(other_org.id)
|
||||
|
||||
conn = get(conn, ~p"/api/v1/checks")
|
||||
|
||||
assert %{"checks" => checks} = json_response(conn, 200)
|
||||
assert Enum.empty?(checks)
|
||||
end
|
||||
end
|
||||
|
||||
describe "create/2" do
|
||||
test "creates HTTP check with valid attributes", %{conn: conn, organization: organization} do
|
||||
params = %{
|
||||
"check" => %{
|
||||
"name" => "Web Health",
|
||||
"check_type" => "http",
|
||||
"config" => %{
|
||||
"url" => "https://example.com/health",
|
||||
"expected_status" => 200
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/checks", params)
|
||||
|
||||
assert %{
|
||||
"id" => id,
|
||||
"name" => "Web Health",
|
||||
"check_type" => "http",
|
||||
"enabled" => true
|
||||
} = json_response(conn, 201)
|
||||
|
||||
assert is_binary(id)
|
||||
|
||||
check = Monitoring.get_check!(id)
|
||||
assert check.organization_id == organization.id
|
||||
end
|
||||
|
||||
test "creates TCP check with valid attributes", %{conn: conn} do
|
||||
params = %{
|
||||
"check" => %{
|
||||
"name" => "MySQL Port",
|
||||
"check_type" => "tcp",
|
||||
"config" => %{
|
||||
"host" => "10.0.0.5",
|
||||
"port" => 3306
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/checks", params)
|
||||
|
||||
assert %{
|
||||
"id" => _id,
|
||||
"name" => "MySQL Port",
|
||||
"check_type" => "tcp"
|
||||
} = json_response(conn, 201)
|
||||
end
|
||||
|
||||
test "creates DNS check with valid attributes", %{conn: conn} do
|
||||
params = %{
|
||||
"check" => %{
|
||||
"name" => "DNS Resolution",
|
||||
"check_type" => "dns",
|
||||
"config" => %{
|
||||
"hostname" => "google.com",
|
||||
"dns_server" => "8.8.8.8",
|
||||
"record_type" => "A"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/checks", params)
|
||||
|
||||
assert %{
|
||||
"id" => _id,
|
||||
"name" => "DNS Resolution",
|
||||
"check_type" => "dns"
|
||||
} = json_response(conn, 201)
|
||||
end
|
||||
|
||||
test "creates ping check with valid attributes", %{conn: conn} do
|
||||
params = %{
|
||||
"check" => %{
|
||||
"name" => "Gateway Ping",
|
||||
"check_type" => "ping",
|
||||
"config" => %{
|
||||
"host" => "10.0.0.1",
|
||||
"count" => 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/checks", params)
|
||||
|
||||
assert %{
|
||||
"id" => _id,
|
||||
"name" => "Gateway Ping",
|
||||
"check_type" => "ping"
|
||||
} = json_response(conn, 201)
|
||||
end
|
||||
|
||||
test "returns 422 when config is invalid for check type", %{conn: conn} do
|
||||
params = %{
|
||||
"check" => %{
|
||||
"name" => "Bad HTTP Check",
|
||||
"check_type" => "http",
|
||||
"config" => %{}
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/checks", params)
|
||||
|
||||
assert %{"errors" => _errors} = json_response(conn, 422)
|
||||
end
|
||||
|
||||
test "returns 422 when name is missing", %{conn: conn} do
|
||||
params = %{
|
||||
"check" => %{
|
||||
"check_type" => "http",
|
||||
"config" => %{"url" => "https://example.com"}
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/checks", params)
|
||||
|
||||
assert %{"errors" => errors} = json_response(conn, 422)
|
||||
assert Map.has_key?(errors, "name")
|
||||
end
|
||||
|
||||
test "returns 400 when check parameter is missing", %{conn: conn} do
|
||||
conn = post(conn, ~p"/api/v1/checks", %{})
|
||||
|
||||
assert %{"error" => "Missing 'check' parameter"} = json_response(conn, 400)
|
||||
end
|
||||
|
||||
test "rejects SNMP check types", %{conn: conn} do
|
||||
params = %{
|
||||
"check" => %{
|
||||
"name" => "SNMP Check",
|
||||
"check_type" => "snmp_sensor",
|
||||
"config" => %{"oid" => "1.3.6.1.2.1.1.3.0"}
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/v1/checks", params)
|
||||
|
||||
assert %{"error" => _} = json_response(conn, 400)
|
||||
end
|
||||
end
|
||||
|
||||
describe "show/2" do
|
||||
test "returns check when it belongs to organization", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
check = create_check(organization.id, %{name: "Show Me"})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/checks/#{check.id}")
|
||||
|
||||
assert %{
|
||||
"id" => id,
|
||||
"name" => "Show Me",
|
||||
"check_type" => "http"
|
||||
} = json_response(conn, 200)
|
||||
|
||||
assert id == check.id
|
||||
end
|
||||
|
||||
test "returns 404 when check does not exist", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/checks/#{Ecto.UUID.generate()}")
|
||||
|
||||
assert %{"error" => "Check not found"} = json_response(conn, 404)
|
||||
end
|
||||
|
||||
test "returns 403 when check belongs to different organization", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
other_check = create_check(other_org.id)
|
||||
|
||||
conn = get(conn, ~p"/api/v1/checks/#{other_check.id}")
|
||||
|
||||
assert %{"error" => "Access denied to this check"} = json_response(conn, 403)
|
||||
end
|
||||
end
|
||||
|
||||
describe "update/2" do
|
||||
test "updates check with valid attributes", %{conn: conn, organization: organization} do
|
||||
check = create_check(organization.id, %{name: "Original"})
|
||||
|
||||
params = %{
|
||||
"check" => %{
|
||||
"name" => "Updated",
|
||||
"interval_seconds" => 120
|
||||
}
|
||||
}
|
||||
|
||||
conn = patch(conn, ~p"/api/v1/checks/#{check.id}", params)
|
||||
|
||||
assert %{
|
||||
"id" => id,
|
||||
"name" => "Updated",
|
||||
"interval_seconds" => 120
|
||||
} = json_response(conn, 200)
|
||||
|
||||
assert id == check.id
|
||||
end
|
||||
|
||||
test "returns 404 when check does not exist", %{conn: conn} do
|
||||
params = %{
|
||||
"check" => %{
|
||||
"name" => "Updated"
|
||||
}
|
||||
}
|
||||
|
||||
conn = patch(conn, ~p"/api/v1/checks/#{Ecto.UUID.generate()}", params)
|
||||
|
||||
assert %{"error" => "Check not found"} = json_response(conn, 404)
|
||||
end
|
||||
|
||||
test "returns 403 when check belongs to different organization", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
other_check = create_check(other_org.id)
|
||||
|
||||
params = %{"check" => %{"name" => "Hacked"}}
|
||||
|
||||
conn = patch(conn, ~p"/api/v1/checks/#{other_check.id}", params)
|
||||
|
||||
assert %{"error" => "Access denied to this check"} = json_response(conn, 403)
|
||||
end
|
||||
|
||||
test "returns 422 with invalid params", %{conn: conn, organization: organization} do
|
||||
check = create_check(organization.id)
|
||||
|
||||
params = %{"check" => %{"name" => ""}}
|
||||
|
||||
conn = patch(conn, ~p"/api/v1/checks/#{check.id}", params)
|
||||
|
||||
assert %{"errors" => errors} = json_response(conn, 422)
|
||||
assert Map.has_key?(errors, "name")
|
||||
end
|
||||
|
||||
test "returns 400 when check parameter is missing", %{conn: conn, organization: organization} do
|
||||
check = create_check(organization.id)
|
||||
|
||||
conn = patch(conn, ~p"/api/v1/checks/#{check.id}", %{})
|
||||
|
||||
assert %{"error" => "Missing 'check' parameter"} = json_response(conn, 400)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete/2" do
|
||||
test "deletes check successfully", %{conn: conn, organization: organization} do
|
||||
check = create_check(organization.id, %{name: "Doomed"})
|
||||
|
||||
conn = delete(conn, ~p"/api/v1/checks/#{check.id}")
|
||||
|
||||
assert %{"success" => true} = json_response(conn, 200)
|
||||
|
||||
assert Monitoring.get_check(check.id) == nil
|
||||
end
|
||||
|
||||
test "returns 404 when check does not exist", %{conn: conn} do
|
||||
conn = delete(conn, ~p"/api/v1/checks/#{Ecto.UUID.generate()}")
|
||||
|
||||
assert %{"error" => "Check not found"} = json_response(conn, 404)
|
||||
end
|
||||
|
||||
test "returns 403 when check belongs to different organization", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
other_check = create_check(other_org.id)
|
||||
|
||||
conn = delete(conn, ~p"/api/v1/checks/#{other_check.id}")
|
||||
|
||||
assert %{"error" => "Access denied to this check"} = json_response(conn, 403)
|
||||
end
|
||||
end
|
||||
|
||||
describe "authentication" do
|
||||
test "returns 401 without authorization header" do
|
||||
conn =
|
||||
build_conn()
|
||||
|> put_req_header("accept", "application/json")
|
||||
|> get(~p"/api/v1/checks")
|
||||
|
||||
assert %{"error" => _message} = json_response(conn, 401)
|
||||
end
|
||||
|
||||
test "returns 401 with invalid token" do
|
||||
conn =
|
||||
build_conn()
|
||||
|> put_req_header("authorization", "Bearer towerops_invalid_token")
|
||||
|> put_req_header("accept", "application/json")
|
||||
|> get(~p"/api/v1/checks")
|
||||
|
||||
assert %{"error" => _message} = json_response(conn, 401)
|
||||
end
|
||||
end
|
||||
end
|
||||
275
test/towerops_web/graphql/resolvers/check_test.exs
Normal file
275
test/towerops_web/graphql/resolvers/check_test.exs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
defmodule ToweropsWeb.GraphQL.Resolvers.CheckTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.ApiTokens
|
||||
alias Towerops.Monitoring
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
|
||||
{:ok, {_token, raw_token}} =
|
||||
ApiTokens.create_api_token(%{
|
||||
organization_id: organization.id,
|
||||
user_id: user.id,
|
||||
name: "Test Token"
|
||||
})
|
||||
|
||||
conn =
|
||||
build_conn()
|
||||
|> put_req_header("authorization", "Bearer #{raw_token}")
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|
||||
%{conn: conn, organization: organization}
|
||||
end
|
||||
|
||||
defp graphql_query(conn, query, variables \\ %{}) do
|
||||
conn
|
||||
|> post("/api/graphql", Jason.encode!(%{query: query, variables: variables}))
|
||||
|> json_response(200)
|
||||
end
|
||||
|
||||
defp create_check(organization_id, attrs) do
|
||||
defaults = %{
|
||||
name: "Test Check",
|
||||
check_type: "http",
|
||||
organization_id: organization_id,
|
||||
config: %{"url" => "https://example.com"}
|
||||
}
|
||||
|
||||
{:ok, check} = Monitoring.create_check(Map.merge(defaults, attrs))
|
||||
check
|
||||
end
|
||||
|
||||
describe "checks query" do
|
||||
test "lists service checks", %{conn: conn, organization: org} do
|
||||
_http = create_check(org.id, %{name: "HTTP Check"})
|
||||
_tcp = create_check(org.id, %{name: "TCP Check", check_type: "tcp", config: %{"host" => "10.0.0.1", "port" => 80}})
|
||||
|
||||
query = """
|
||||
{
|
||||
checks {
|
||||
id
|
||||
name
|
||||
checkType
|
||||
enabled
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = graphql_query(conn, query)
|
||||
|
||||
assert %{"data" => %{"checks" => checks}} = result
|
||||
assert length(checks) == 2
|
||||
|
||||
names = Enum.map(checks, & &1["name"])
|
||||
assert "HTTP Check" in names
|
||||
assert "TCP Check" in names
|
||||
end
|
||||
|
||||
test "filters by check_type", %{conn: conn, organization: org} do
|
||||
_http = create_check(org.id, %{name: "HTTP Check"})
|
||||
_tcp = create_check(org.id, %{name: "TCP Check", check_type: "tcp", config: %{"host" => "10.0.0.1", "port" => 80}})
|
||||
|
||||
query = """
|
||||
query($type: String) {
|
||||
checks(checkType: $type) {
|
||||
name
|
||||
checkType
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = graphql_query(conn, query, %{"type" => "tcp"})
|
||||
|
||||
assert %{"data" => %{"checks" => [check]}} = result
|
||||
assert check["name"] == "TCP Check"
|
||||
end
|
||||
|
||||
test "excludes SNMP checks", %{conn: conn, organization: org} do
|
||||
_http = create_check(org.id, %{name: "HTTP Check"})
|
||||
|
||||
{:ok, _snmp} =
|
||||
Monitoring.create_check(%{
|
||||
name: "SNMP Sensor",
|
||||
check_type: "snmp_sensor",
|
||||
organization_id: org.id,
|
||||
config: %{"oid" => "1.3.6.1.2.1.1.3.0"}
|
||||
})
|
||||
|
||||
query = """
|
||||
{
|
||||
checks {
|
||||
name
|
||||
checkType
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = graphql_query(conn, query)
|
||||
|
||||
assert %{"data" => %{"checks" => checks}} = result
|
||||
assert length(checks) == 1
|
||||
assert List.first(checks)["checkType"] == "http"
|
||||
end
|
||||
end
|
||||
|
||||
describe "check query" do
|
||||
test "returns a single check", %{conn: conn, organization: org} do
|
||||
check = create_check(org.id, %{name: "Single Check"})
|
||||
|
||||
query = """
|
||||
query($id: ID!) {
|
||||
check(id: $id) {
|
||||
id
|
||||
name
|
||||
checkType
|
||||
config
|
||||
enabled
|
||||
intervalSeconds
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = graphql_query(conn, query, %{"id" => check.id})
|
||||
|
||||
assert %{"data" => %{"check" => data}} = result
|
||||
assert data["id"] == check.id
|
||||
assert data["name"] == "Single Check"
|
||||
assert data["checkType"] == "http"
|
||||
assert data["enabled"] == true
|
||||
end
|
||||
|
||||
test "returns error for non-existent check", %{conn: conn} do
|
||||
query = """
|
||||
query($id: ID!) {
|
||||
check(id: $id) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = graphql_query(conn, query, %{"id" => Ecto.UUID.generate()})
|
||||
|
||||
assert %{"errors" => [%{"message" => "Check not found"}]} = result
|
||||
end
|
||||
end
|
||||
|
||||
describe "createCheck mutation" do
|
||||
test "creates an HTTP check", %{conn: conn, organization: org} do
|
||||
mutation = """
|
||||
mutation($input: CheckInput!) {
|
||||
createCheck(input: $input) {
|
||||
id
|
||||
name
|
||||
checkType
|
||||
enabled
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
input = %{
|
||||
"name" => "New HTTP Check",
|
||||
"check_type" => "http",
|
||||
"config" => Jason.encode!(%{"url" => "https://example.com/health"})
|
||||
}
|
||||
|
||||
result = graphql_query(conn, mutation, %{"input" => input})
|
||||
|
||||
assert %{"data" => %{"createCheck" => check}} = result
|
||||
assert check["name"] == "New HTTP Check"
|
||||
assert check["checkType"] == "http"
|
||||
assert check["enabled"] == true
|
||||
|
||||
# Verify it exists in DB
|
||||
assert Monitoring.get_check(check["id"]).organization_id == org.id
|
||||
end
|
||||
|
||||
test "returns error for invalid check type", %{conn: conn} do
|
||||
mutation = """
|
||||
mutation($input: CheckInput!) {
|
||||
createCheck(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
input = %{
|
||||
"name" => "Bad Check",
|
||||
"check_type" => "snmp_sensor",
|
||||
"config" => Jason.encode!(%{"oid" => "1.3.6.1.2.1.1.3.0"})
|
||||
}
|
||||
|
||||
result = graphql_query(conn, mutation, %{"input" => input})
|
||||
|
||||
assert %{"errors" => [%{"message" => message}]} = result
|
||||
assert message =~ "Invalid check type"
|
||||
end
|
||||
end
|
||||
|
||||
describe "updateCheck mutation" do
|
||||
test "updates an existing check", %{conn: conn, organization: org} do
|
||||
check = create_check(org.id, %{name: "Original"})
|
||||
|
||||
mutation = """
|
||||
mutation($id: ID!, $input: CheckInput!) {
|
||||
updateCheck(id: $id, input: $input) {
|
||||
id
|
||||
name
|
||||
intervalSeconds
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
input = %{"name" => "Updated", "interval_seconds" => 120}
|
||||
|
||||
result = graphql_query(conn, mutation, %{"id" => check.id, "input" => input})
|
||||
|
||||
assert %{"data" => %{"updateCheck" => updated}} = result
|
||||
assert updated["name"] == "Updated"
|
||||
assert updated["intervalSeconds"] == 120
|
||||
end
|
||||
end
|
||||
|
||||
describe "deleteCheck mutation" do
|
||||
test "deletes a check", %{conn: conn, organization: org} do
|
||||
check = create_check(org.id, %{name: "Doomed"})
|
||||
|
||||
mutation = """
|
||||
mutation($id: ID!) {
|
||||
deleteCheck(id: $id) {
|
||||
success
|
||||
message
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = graphql_query(conn, mutation, %{"id" => check.id})
|
||||
|
||||
assert %{"data" => %{"deleteCheck" => %{"success" => true}}} = result
|
||||
assert Monitoring.get_check(check.id) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "authentication" do
|
||||
test "returns 401 without auth" do
|
||||
conn = put_req_header(build_conn(), "content-type", "application/json")
|
||||
|
||||
query = """
|
||||
{
|
||||
checks {
|
||||
id
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
resp = post(conn, "/api/graphql", Jason.encode!(%{query: query}))
|
||||
|
||||
assert resp.status == 401
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue