diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 898d2f85..83fd4ac0 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -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 diff --git a/lib/towerops/monitoring/check.ex b/lib/towerops/monitoring/check.ex index 14510717..c946c224 100644 --- a/lib/towerops/monitoring/check.ex +++ b/lib/towerops/monitoring/check.ex @@ -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) diff --git a/lib/towerops/monitoring/executors/ping_executor.ex b/lib/towerops/monitoring/executors/ping_executor.ex new file mode 100644 index 00000000..78372768 --- /dev/null +++ b/lib/towerops/monitoring/executors/ping_executor.ex @@ -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 diff --git a/lib/towerops/workers/check_executor_worker.ex b/lib/towerops/workers/check_executor_worker.ex index 332b5324..e95e9a11 100644 --- a/lib/towerops/workers/check_executor_worker.ex +++ b/lib/towerops/workers/check_executor_worker.ex @@ -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) diff --git a/lib/towerops_web/controllers/api/v1/checks_controller.ex b/lib/towerops_web/controllers/api/v1/checks_controller.ex new file mode 100644 index 00000000..b49bde9e --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/checks_controller.ex @@ -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 diff --git a/lib/towerops_web/controllers/api_docs_html/index.html.heex b/lib/towerops_web/controllers/api_docs_html/index.html.heex index 2c86ca3f..49e2d23f 100644 --- a/lib/towerops_web/controllers/api_docs_html/index.html.heex +++ b/lib/towerops_web/controllers/api_docs_html/index.html.heex @@ -130,6 +130,15 @@ Integrations +
+ 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. +
+ + +http, tcp, dns, or ping.
+ url, method, expected_status, verify_ssl, follow_redirects, regex. TCP: host, port, send, expect. DNS: hostname, record_type, server, expected. Ping: host, count.
+ soft or hard.
+ + Lists all service checks for the authenticated organization. Only returns HTTP, TCP, DNS, and ping checks. +
+ +http, tcp, dns, or ping.
+ Request
+<%= raw(~S"""
+curl -G https://towerops.net/api/v1/checks \
+ -H "Authorization: Bearer {token}" \
+ -d check_type=http
+""") %>
+ Response
+<%= 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"
+ }
+ ]
+}
+""") %>
+ + Creates a new service check for the authenticated organization. The check is automatically scheduled if enabled. +
+ +http, tcp, dns, ping.
+ true.
+ 60.
+ Request
+<%= 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}}}'
+""") %>
+ Response 201 Created
+<%= 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"
+}
+""") %>
+ + Retrieves a single check by its UUID. +
+Request
+<%= raw(~S"""
+curl https://towerops.net/api/v1/checks/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
+ -H "Authorization: Bearer {token}"
+""") %>
+ + Updates an existing check. Only the provided fields will be updated. +
+Request
+<%= 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}}'
+""") %>
+ + Deletes a check and cancels any scheduled executions. +
+Request
+<%= raw(~S"""
+curl -X DELETE https://towerops.net/api/v1/checks/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
+ -H "Authorization: Bearer {token}"
+""") %>
+ Response
+<%= raw(~S"""
+{
+ "success": true
+}
+""") %>
+