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:
Graham McIntire 2026-03-16 18:40:18 -05:00 committed by graham
parent d0e2ead923
commit 28e97ff5f0
14 changed files with 1823 additions and 1 deletions

View file

@ -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

View file

@ -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)

View 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

View file

@ -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)

View 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

View file

@ -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">

View 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

View file

@ -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))

View 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

View file

@ -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]

View file

@ -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

View 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

View 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

View 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