From c6913685ee65cbe13c9b1e0f6c1e3392b01d179d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 16 Mar 2026 17:25:02 -0500 Subject: [PATCH] wire up service checks (HTTP/TCP/DNS) to remote agents (#50) send check_jobs protobuf to agents during job dispatch cycle, handle check_result messages back with state transitions and alert creation/resolution. auto-assign checks to device's effective agent token on creation, broadcast check changes via PubSub so connected agents get immediate updates. add edit/delete support for service checks on device show page with form pre-population from existing check config. auto-fill TCP host from device IP address. includes validate_check_result/1 for protobuf validation and list_checks_for_agent/2 for querying agent-assigned service checks. also fixes pre-existing broken device_type_icon reference in device index template. Reviewed-on: https://git.mcintire.me/graham/towerops-web/pulls/50 --- lib/towerops/agent/validator.ex | 43 ++++ lib/towerops/monitoring.ex | 64 ++++- lib/towerops_web/channels/agent_channel.ex | 222 ++++++++++++++++++ .../live/check_live/form_component.ex | 164 +++++++++++-- .../live/device_live/index.html.heex | 2 +- lib/towerops_web/live/device_live/show.ex | 60 ++++- .../live/device_live/show.html.heex | 43 +++- test/towerops/agent/validator_test.exs | 90 +++++++ test/towerops/monitoring_test.exs | 74 ++++++ 9 files changed, 715 insertions(+), 47 deletions(-) diff --git a/lib/towerops/agent/validator.ex b/lib/towerops/agent/validator.ex index 273c209c..7d40a938 100644 --- a/lib/towerops/agent/validator.ex +++ b/lib/towerops/agent/validator.ex @@ -30,6 +30,7 @@ defmodule Towerops.Agent.Validator do alias Towerops.Agent.AgentError alias Towerops.Agent.AgentHeartbeat + alias Towerops.Agent.CheckResult alias Towerops.Agent.CredentialTestResult alias Towerops.Agent.InterfaceStat alias Towerops.Agent.LldpNeighbor @@ -199,6 +200,22 @@ defmodule Towerops.Agent.Validator do end end + @doc """ + Validate CheckResult message from binary protobuf. + + Decodes the binary and validates check_id (UUID), status (0-3), response_time, and output length. + """ + @spec validate_check_result(binary()) :: validation_result(CheckResult.t()) + def validate_check_result(binary) when is_binary(binary) do + with {:ok, result} <- safe_decode(CheckResult, binary), + :ok <- validate_check_id(result.check_id), + :ok <- validate_check_status(result.status), + :ok <- validate_check_response_time(result.response_time_ms), + :ok <- validate_string(result.output, "output") do + {:ok, result} + end + end + ## Private Validation Functions # Safe decode with error handling @@ -613,4 +630,30 @@ defmodule Towerops.Agent.Validator do end end) end + + # Validate check ID (UUID format, same as device_id) + defp validate_check_id(id) when is_binary(id) do + if String.match?(id, ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) do + :ok + else + {:error, {:invalid_check_id, "Check ID must be valid UUID"}} + end + end + + defp validate_check_id(_), do: {:error, {:invalid_check_id, "Check ID must be a string"}} + + # Validate check status (0=OK, 1=WARNING, 2=CRITICAL, 3=UNKNOWN) + defp validate_check_status(status) when is_integer(status) and status >= 0 and status <= 3 do + :ok + end + + defp validate_check_status(_), do: {:error, {:invalid_check_status, "Status must be 0-3"}} + + # Validate check response time (non-negative, within max) + defp validate_check_response_time(ms) when is_float(ms) and ms >= 0.0 and ms <= @max_response_time_ms do + :ok + end + + defp validate_check_response_time(_), + do: {:error, {:invalid_response_time, "Response time must be 0 to #{@max_response_time_ms}ms"}} end diff --git a/lib/towerops/monitoring.ex b/lib/towerops/monitoring.ex index 70131203..1c808184 100644 --- a/lib/towerops/monitoring.ex +++ b/lib/towerops/monitoring.ex @@ -48,6 +48,23 @@ defmodule Towerops.Monitoring do from c in query, where: c.enabled == ^enabled end + @doc """ + Returns the list of service checks assigned to a specific agent. + + Only returns http/tcp/dns checks (service checks that agents can execute). + """ + def list_checks_for_agent(agent_token_id, opts \\ []) do + query = + from c in Check, + where: c.agent_token_id == ^agent_token_id, + where: c.check_type in ["http", "tcp", "dns"], + order_by: [asc: c.name] + + query + |> maybe_filter_by_enabled(opts[:enabled]) + |> Repo.all() + end + @doc """ Gets a single check. """ @@ -69,25 +86,58 @@ defmodule Towerops.Monitoring do Creates a check. """ def create_check(attrs \\ %{}) do - %Check{} - |> Check.changeset(attrs) - |> Repo.insert() + result = + %Check{} + |> Check.changeset(attrs) + |> Repo.insert() + + case result do + {:ok, check} -> broadcast_check_change(check) + _ -> :ok + end + + result end @doc """ Updates a check. """ def update_check(%Check{} = check, attrs) do - check - |> Check.changeset(attrs) - |> Repo.update() + result = + check + |> Check.changeset(attrs) + |> Repo.update() + + case result do + {:ok, updated} -> broadcast_check_change(updated) + _ -> :ok + end + + result end @doc """ Deletes a check. """ def delete_check(%Check{} = check) do - Repo.delete(check) + result = Repo.delete(check) + + case result do + {:ok, deleted} -> broadcast_check_change(deleted) + _ -> :ok + end + + result + end + + defp broadcast_check_change(%Check{agent_token_id: nil}), do: :ok + + defp broadcast_check_change(%Check{agent_token_id: agent_token_id}) do + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "checks:agent:#{agent_token_id}", + :checks_changed + ) end @doc """ diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index d9c21b19..972f38b1 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -23,12 +23,18 @@ defmodule ToweropsWeb.AgentChannel do alias Towerops.Agent.AgentJob alias Towerops.Agent.AgentJobList + alias Towerops.Agent.Check, as: CheckProto + alias Towerops.Agent.CheckList + alias Towerops.Agent.CheckResult, as: CheckResultProto + alias Towerops.Agent.DnsCheckConfig + alias Towerops.Agent.HttpCheckConfig alias Towerops.Agent.LldpTopologyResult alias Towerops.Agent.MikrotikCommand alias Towerops.Agent.MikrotikDevice alias Towerops.Agent.MikrotikResult alias Towerops.Agent.SnmpDevice alias Towerops.Agent.SnmpQuery + alias Towerops.Agent.TcpCheckConfig alias Towerops.Agent.Validator alias Towerops.Agents alias Towerops.Alerts @@ -100,6 +106,9 @@ defmodule ToweropsWeb.AgentChannel do # Subscribe to live poll requests for this agent _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:live_poll") + # Subscribe to check changes for this agent (service checks added/removed/updated) + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "checks:agent:#{agent_token.id}") + # Subscribe to token lifecycle events (disable/delete triggers disconnect) _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:lifecycle") @@ -159,6 +168,7 @@ defmodule ToweropsWeb.AgentChannel do cancel_poll_timer(socket) updated_socket = build_and_push_jobs(socket) + build_and_push_check_jobs(updated_socket) # Schedule next job dispatch cycle interval_ms = poll_interval_ms() @@ -215,6 +225,12 @@ defmodule ToweropsWeb.AgentChannel do end end + # Handle check changes — push updated check list to agent + def handle_info(:checks_changed, socket) do + build_and_push_check_jobs(socket) + {:noreply, socket} + end + # Handle token disabled/deleted — disconnect the agent def handle_info(:token_disabled, socket) do Logger.warning("Agent token disabled, disconnecting", @@ -599,6 +615,51 @@ defmodule ToweropsWeb.AgentChannel do end end + @spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) :: + {:noreply, socket()} + def handle_in("check_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do + with {:ok, binary} <- safe_base64_decode(binary_b64), + {:ok, result} <- Validator.validate_check_result(binary) do + maybe_debug_log(socket, "Received check result from agent", + check_id: result.check_id, + status: result.status, + response_time_ms: result.response_time_ms, + binary_size: byte_size(binary_b64) + ) + + case store_check_result(result, socket) do + :ok -> + :ok + + {:error, reason} -> + Logger.error("Failed to store check result", + check_id: result.check_id, + reason: inspect(reason) + ) + end + + {:noreply, socket} + else + {:error, {type, message}} -> + Logger.error("Invalid check result from agent: #{type} - #{message}", + agent_token_id: socket.assigns.agent_token_id, + error_type: type, + error_message: message, + binary_size: byte_size(binary_b64) + ) + + {:noreply, socket} + + {:error, :base64_decode_failed} -> + Logger.error("Failed to decode check result (invalid base64)", + agent_token_id: socket.assigns.agent_token_id, + binary_size: byte_size(binary_b64) + ) + + {:noreply, socket} + end + end + @spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) :: {:noreply, socket()} def handle_in("lldp_topology_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do @@ -811,6 +872,85 @@ defmodule ToweropsWeb.AgentChannel do socket end + # Builds service check jobs for the agent and pushes them via "check_jobs" event. + defp build_and_push_check_jobs(socket) do + agent_token_id = socket.assigns.agent_token_id + checks = Monitoring.list_checks_for_agent(agent_token_id, enabled: true) + + if checks != [] do + proto_checks = Enum.map(checks, &build_check_protobuf/1) + check_list = %CheckList{checks: proto_checks} + binary = CheckList.encode(check_list) + + Logger.info("Sending #{length(proto_checks)} check jobs to agent", + agent_token_id: agent_token_id, + check_ids: inspect(Enum.map(checks, & &1.id)) + ) + + push(socket, "check_jobs", %{binary: Base.encode64(binary)}) + end + end + + defp build_check_protobuf(check) do + config = check.config || %{} + + base_fields = [ + id: check.id, + check_type: check.check_type, + interval_seconds: check.interval_seconds, + timeout_ms: check.timeout_ms + ] + + case check.check_type do + "http" -> + struct!( + CheckProto, + base_fields ++ + [ + http: %HttpCheckConfig{ + url: config["url"] || "", + method: config["method"] || "GET", + expected_status: config["expected_status"] || 200, + verify_ssl: config["verify_ssl"] != false, + regex: config["regex"] || "", + follow_redirects: config["follow_redirects"] != false + } + ] + ) + + "tcp" -> + struct!( + CheckProto, + base_fields ++ + [ + tcp: %TcpCheckConfig{ + host: config["host"] || "", + port: config["port"] || 0, + send: config["send"] || "", + expect: config["expect"] || "" + } + ] + ) + + "dns" -> + struct!( + CheckProto, + base_fields ++ + [ + dns: %DnsCheckConfig{ + hostname: config["hostname"] || "", + server: config["server"] || "", + record_type: config["record_type"] || "A", + expected: config["expected"] || "" + } + ] + ) + + _ -> + struct!(CheckProto, base_fields) + end + end + @spec build_jobs_for_agent(Ecto.UUID.t()) :: [AgentJob.t()] defp build_jobs_for_agent(agent_token_id) do agent_token_id @@ -1400,6 +1540,88 @@ defmodule ToweropsWeb.AgentChannel do end end + defp store_check_result(%CheckResultProto{} = result, socket) do + organization_id = socket.assigns.organization_id + agent_token_id = socket.assigns.agent_token_id + + case Monitoring.get_check(result.check_id) do + nil -> + Logger.error("Check not found for check result: #{result.check_id}") + {:error, :check_not_found} + + check -> + if check.organization_id == organization_id do + now = DateTime.truncate(DateTime.utc_now(), :second) + + # Record check result + case Monitoring.create_check_result(%{ + check_id: check.id, + organization_id: organization_id, + status: result.status, + output: result.output, + response_time_ms: result.response_time_ms, + checked_at: now, + agent_token_id: agent_token_id + }) do + {:ok, _} -> + # Update check state (soft/hard transitions) + old_state = check.current_state + {:ok, updated_check} = Monitoring.update_check_state(check, result.status, result.output) + + # Handle alert creation/resolution on hard state changes + handle_check_state_change(old_state, updated_check, result.output) + + # Broadcast for LiveView refresh + if check.device_id do + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{check.device_id}", + {:monitoring_check_updated, check.device_id} + ) + end + + :ok + + {:error, changeset} -> + Logger.error("Failed to store check result", + check_id: check.id, + errors: inspect(changeset.errors) + ) + + {:error, :storage_failed} + end + else + Logger.error("Check #{result.check_id} not in agent's organization") + {:error, :wrong_organization} + end + end + end + + defp handle_check_state_change(old_state, check, output) do + # State changed from OK to problem and is now hard state + if old_state == 0 and check.current_state in [1, 2] and check.current_state_type == "hard" do + if !Alerts.has_active_check_alert?(check.id) do + severity = if check.current_state == 1, do: 1, else: 2 + + Alerts.create_alert(%{ + check_id: check.id, + device_id: check.device_id, + organization_id: check.organization_id, + alert_type: "check_#{check.check_type}", + severity: severity, + message: "Check '#{check.name}' is #{Monitoring.Check.state_label(check.current_state)}", + output: output, + triggered_at: DateTime.utc_now() + }) + end + end + + # State changed from problem to OK + if old_state in [1, 2] and check.current_state == 0 do + Alerts.resolve_check_alerts(check.id) + end + end + defp store_lldp_neighbors(%LldpTopologyResult{} = result) do now = DateTime.utc_now() device_id = result.device_id diff --git a/lib/towerops_web/live/check_live/form_component.ex b/lib/towerops_web/live/check_live/form_component.ex index febb31bc..78e29eff 100644 --- a/lib/towerops_web/live/check_live/form_component.ex +++ b/lib/towerops_web/live/check_live/form_component.ex @@ -2,6 +2,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do @moduledoc false use ToweropsWeb, :live_component + alias Towerops.Agents alias Towerops.Monitoring @impl true @@ -24,7 +25,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do

- Add Service Check + {if @action == :edit, do: "Edit Service Check", else: "Add Service Check"}

@@ -96,8 +98,11 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
- <.button type="submit" phx-disable-with="Creating..."> - Create Check + <.button + type="submit" + phx-disable-with={if @action == :edit, do: "Saving...", else: "Creating..."} + > + {if @action == :edit, do: "Save Changes", else: "Create Check"} <.button type="button" @@ -251,27 +256,80 @@ defmodule ToweropsWeb.CheckLive.FormComponent do @impl true def update(assigns, socket) do - changeset = Monitoring.change_check(%Monitoring.Check{}) + action = assigns[:action] || :new + check = assigns[:check] + + {selected_type, changeset} = + if action == :edit && check do + # Editing: populate from existing check + type = check.check_type + config = check.config || %{} + + form_attrs = + config + |> config_to_form_fields(type) + |> Map.merge(%{ + "name" => check.name, + "check_type" => type, + "interval_seconds" => check.interval_seconds, + "timeout_ms" => check.timeout_ms + }) + + {type, Monitoring.change_check(check, form_attrs)} + else + # Creating: start with defaults + {"http", Monitoring.change_check(%Monitoring.Check{})} + end {:ok, socket |> assign(assigns) - |> assign(:selected_type, "http") + |> assign(:action, action) + |> assign(:selected_type, selected_type) |> assign(:form, to_form(changeset)) - |> assign_default_config("http")} + |> assign_default_config(selected_type)} end @impl true def handle_event("change_type", %{"check" => %{"check_type" => type}}, socket) do - {:noreply, socket |> assign(:selected_type, type) |> assign_default_config(type)} + socket = + socket + |> assign(:selected_type, type) + |> assign_default_config(type) + + # Auto-fill TCP host from device IP + socket = + if type == "tcp" && socket.assigns[:device] do + device = socket.assigns.device + + if device.ip_address && device.ip_address != "" do + ip_str = to_string(device.ip_address) + form = socket.assigns.form + params = form.params || %{} + params = Map.put(params, "host", ip_str) + changeset = Monitoring.change_check(%Monitoring.Check{}, params) + assign(socket, :form, to_form(changeset)) + else + socket + end + else + socket + end + + {:noreply, socket} end @impl true def handle_event("validate", %{"check" => check_params}, socket) do check_params = merge_config_params(check_params, socket.assigns.selected_type) + check = + if socket.assigns.action == :edit, + do: socket.assigns.check, + else: %Monitoring.Check{} + changeset = - %Monitoring.Check{} + check |> Monitoring.change_check(check_params) |> Map.put(:action, :validate) @@ -282,22 +340,10 @@ defmodule ToweropsWeb.CheckLive.FormComponent do def handle_event("save", %{"check" => check_params}, socket) do check_params = merge_config_params(check_params, socket.assigns.selected_type) - check_params = - check_params - |> Map.put("organization_id", socket.assigns.device.organization_id) - |> Map.put("device_id", socket.assigns.device.id) - |> Map.put("source_type", "manual") - - case Monitoring.create_check(check_params) do - {:ok, check} -> - # Schedule first execution - Monitoring.schedule_check(check) - - notify_parent({:check_created, check}) - {:noreply, socket} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} + if socket.assigns.action == :edit do + save_edit(socket, check_params) + else + save_new(socket, check_params) end end @@ -307,6 +353,72 @@ defmodule ToweropsWeb.CheckLive.FormComponent do {:noreply, socket} end + defp save_new(socket, check_params) do + device = socket.assigns.device + effective_agent_token_id = Agents.get_effective_agent_token(device) + + check_params = + check_params + |> Map.put("organization_id", device.organization_id) + |> Map.put("device_id", device.id) + |> Map.put("source_type", "manual") + |> Map.put("agent_token_id", effective_agent_token_id) + + case Monitoring.create_check(check_params) do + {:ok, check} -> + Monitoring.schedule_check(check) + notify_parent({:check_created, check}) + {:noreply, socket} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_edit(socket, check_params) do + check = socket.assigns.check + + case Monitoring.update_check(check, check_params) do + {:ok, updated_check} -> + notify_parent({:check_updated, updated_check}) + {:noreply, socket} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp config_to_form_fields(config, "http") do + %{ + "url" => config["url"] || "", + "method" => config["method"] || "GET", + "expected_status" => to_string(config["expected_status"] || 200), + "verify_ssl" => to_string(config["verify_ssl"] != false), + "follow_redirects" => to_string(config["follow_redirects"] != false), + "content_match" => config["regex"] || "" + } + end + + defp config_to_form_fields(config, "tcp") do + %{ + "host" => config["host"] || "", + "port" => to_string(config["port"] || ""), + "send_string" => config["send"] || "", + "expect_string" => config["expect"] || "" + } + end + + defp config_to_form_fields(config, "dns") do + %{ + "hostname" => config["hostname"] || "", + "record_type" => config["record_type"] || "A", + "dns_server" => config["server"] || "", + "expected_result" => config["expected"] || "" + } + end + + defp config_to_form_fields(_config, _type), do: %{} + defp assign_default_config(socket, type) do config = case type do @@ -320,7 +432,9 @@ defmodule ToweropsWeb.CheckLive.FormComponent do } "tcp" -> - %{"host" => "", "port" => ""} + device = socket.assigns[:device] + host = if device && device.ip_address, do: to_string(device.ip_address), else: "" + %{"host" => host, "port" => ""} "dns" -> %{"hostname" => "", "record_type" => "A"} diff --git a/lib/towerops_web/live/device_live/index.html.heex b/lib/towerops_web/live/device_live/index.html.heex index c9f4fa7b..82a9921d 100644 --- a/lib/towerops_web/live/device_live/index.html.heex +++ b/lib/towerops_web/live/device_live/index.html.heex @@ -587,7 +587,7 @@ <:col :let={discovered} label={t("Type")}>
<.icon - name={device_type_icon(discovered.device_type)} + name="hero-server" class="h-4 w-4 text-gray-500 dark:text-gray-400" /> diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index f08caae9..e3c87762 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -57,7 +57,10 @@ defmodule ToweropsWeb.DeviceLive.Show do @impl true def mount(_params, _session, socket) do - {:ok, assign(socket, :show_check_form, false)} + {:ok, + socket + |> assign(:show_check_form, false) + |> assign(:edit_check, nil)} end @impl true @@ -235,13 +238,27 @@ defmodule ToweropsWeb.DeviceLive.Show do {:noreply, socket |> assign(:show_check_form, false) + |> assign(:edit_check, nil) |> put_flash(:info, t("Check created successfully")) |> load_equipment_data(socket.assigns.device.id)} end + @impl true + def handle_info({FormComponent, {:check_updated, _check}}, socket) do + {:noreply, + socket + |> assign(:show_check_form, false) + |> assign(:edit_check, nil) + |> put_flash(:info, t("Check updated successfully")) + |> load_equipment_data(socket.assigns.device.id)} + end + @impl true def handle_info({FormComponent, :close}, socket) do - {:noreply, assign(socket, :show_check_form, false)} + {:noreply, + socket + |> assign(:show_check_form, false) + |> assign(:edit_check, nil)} end # Private functions @@ -1420,7 +1437,44 @@ defmodule ToweropsWeb.DeviceLive.Show do @impl true def handle_event("add_check", _params, socket) do - {:noreply, assign(socket, :show_check_form, true)} + {:noreply, + socket + |> assign(:show_check_form, true) + |> assign(:edit_check, nil)} + end + + @impl true + def handle_event("edit_check", %{"id" => check_id}, socket) do + case Monitoring.get_check(check_id) do + nil -> + {:noreply, put_flash(socket, :error, t("Check not found"))} + + check -> + {:noreply, + socket + |> assign(:show_check_form, true) + |> assign(:edit_check, check)} + end + end + + @impl true + def handle_event("delete_check", %{"id" => check_id}, socket) do + case Monitoring.get_check(check_id) do + nil -> + {:noreply, put_flash(socket, :error, t("Check not found"))} + + check -> + case Monitoring.delete_check(check) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, t("Check deleted")) + |> assign_checks_data(socket.assigns.device.id)} + + {:error, _} -> + {:noreply, put_flash(socket, :error, t("Failed to delete check"))} + end + end end @impl true diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index cd325705..da3b9c46 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -2720,14 +2720,35 @@ <% end %> - <.link - navigate={ - ~p"/devices/#{@device.id}/graph/check?check_id=#{check.id}&range=24h" - } - class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium" - > - {t("Graph")} - +
+ <.link + navigate={ + ~p"/devices/#{@device.id}/graph/check?check_id=#{check.id}&range=24h" + } + class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium" + > + {t("Graph")} + + <%= if check.check_type in ["http", "tcp", "dns"] do %> + + + <% end %> +
<% end %> @@ -3285,9 +3306,9 @@ <%= if @show_check_form do %> <.live_component module={FormComponent} - id={:new} - title="Add Service Check" - action={:new} + id={if @edit_check, do: @edit_check.id, else: :new} + action={if @edit_check, do: :edit, else: :new} + check={@edit_check} device={@device} /> <% end %> diff --git a/test/towerops/agent/validator_test.exs b/test/towerops/agent/validator_test.exs index 28d6807c..ba346ae5 100644 --- a/test/towerops/agent/validator_test.exs +++ b/test/towerops/agent/validator_test.exs @@ -3,6 +3,7 @@ defmodule Towerops.Agent.ValidatorTest do alias Towerops.Agent.AgentError alias Towerops.Agent.AgentHeartbeat + alias Towerops.Agent.CheckResult alias Towerops.Agent.CredentialTestResult alias Towerops.Agent.InterfaceStat alias Towerops.Agent.Metric @@ -1516,4 +1517,93 @@ defmodule Towerops.Agent.ValidatorTest do assert {:error, {:invalid_hostname, _}} = Validator.validate_heartbeat(binary) end end + + describe "validate_check_result/1" do + test "validates valid check result" do + result = %CheckResult{ + check_id: "550e8400-e29b-41d4-a716-446655440000", + status: 0, + output: "HTTP 200 OK", + response_time_ms: 42.5, + timestamp: 1_700_000_000 + } + + binary = CheckResult.encode(result) + assert {:ok, validated} = Validator.validate_check_result(binary) + assert validated.check_id == "550e8400-e29b-41d4-a716-446655440000" + assert validated.status == 0 + assert validated.output == "HTTP 200 OK" + end + + test "validates all valid statuses (0-3)" do + for status <- [0, 1, 2, 3] do + result = %CheckResult{ + check_id: "550e8400-e29b-41d4-a716-446655440000", + status: status, + output: "", + response_time_ms: 0.0, + timestamp: 1_700_000_000 + } + + binary = CheckResult.encode(result) + assert {:ok, _} = Validator.validate_check_result(binary) + end + end + + test "rejects invalid check_id format" do + result = %CheckResult{ + check_id: "not-a-uuid", + status: 0, + output: "", + response_time_ms: 0.0, + timestamp: 1_700_000_000 + } + + binary = CheckResult.encode(result) + assert {:error, {:invalid_check_id, _}} = Validator.validate_check_result(binary) + end + + test "rejects status > 3" do + result = %CheckResult{ + check_id: "550e8400-e29b-41d4-a716-446655440000", + status: 4, + output: "", + response_time_ms: 0.0, + timestamp: 1_700_000_000 + } + + binary = CheckResult.encode(result) + assert {:error, {:invalid_check_status, _}} = Validator.validate_check_result(binary) + end + + test "rejects negative response time" do + result = %CheckResult{ + check_id: "550e8400-e29b-41d4-a716-446655440000", + status: 0, + output: "", + response_time_ms: -1.0, + timestamp: 1_700_000_000 + } + + binary = CheckResult.encode(result) + assert {:error, {:invalid_response_time, _}} = Validator.validate_check_result(binary) + end + + test "rejects excessive response time" do + result = %CheckResult{ + check_id: "550e8400-e29b-41d4-a716-446655440000", + status: 0, + output: "", + response_time_ms: 99_999.0, + timestamp: 1_700_000_000 + } + + binary = CheckResult.encode(result) + assert {:error, {:invalid_response_time, _}} = Validator.validate_check_result(binary) + end + + test "rejects invalid protobuf binary" do + assert {:error, {:decode_error, _}} = Validator.validate_check_result("garbage") + end + end end diff --git a/test/towerops/monitoring_test.exs b/test/towerops/monitoring_test.exs index 75456fbf..13217201 100644 --- a/test/towerops/monitoring_test.exs +++ b/test/towerops/monitoring_test.exs @@ -2,6 +2,7 @@ defmodule Towerops.MonitoringTest do use Towerops.DataCase import Towerops.AccountsFixtures + import Towerops.AgentsFixtures import Towerops.OrganizationsFixtures alias Towerops.Monitoring @@ -103,6 +104,79 @@ defmodule Towerops.MonitoringTest do end end + describe "list_checks_for_agent/2" do + test "returns service checks assigned to an agent", %{organization: org} do + {:ok, agent_token, _token_string} = agent_token_fixture(org.id) + + {:ok, http_check} = + Monitoring.create_check( + valid_check_attrs(org.id, %{name: "HTTP Check", check_type: "http", agent_token_id: agent_token.id}) + ) + + {:ok, tcp_check} = + Monitoring.create_check( + valid_check_attrs(org.id, %{ + name: "TCP Check", + check_type: "tcp", + config: %{"host" => "10.0.0.1", "port" => 80}, + agent_token_id: agent_token.id + }) + ) + + checks = Monitoring.list_checks_for_agent(agent_token.id) + ids = Enum.map(checks, & &1.id) + assert http_check.id in ids + assert tcp_check.id in ids + end + + test "excludes non-service check types", %{organization: org} do + {:ok, agent_token, _token_string} = agent_token_fixture(org.id) + + {:ok, _snmp} = + Monitoring.create_check( + valid_check_attrs(org.id, %{ + name: "SNMP Sensor", + check_type: "snmp_sensor", + config: %{"oid" => "1.3.6.1"}, + agent_token_id: agent_token.id + }) + ) + + {:ok, _http} = + Monitoring.create_check(valid_check_attrs(org.id, %{name: "HTTP Check", agent_token_id: agent_token.id})) + + checks = Monitoring.list_checks_for_agent(agent_token.id) + assert length(checks) == 1 + assert hd(checks).check_type == "http" + end + + test "filters by enabled", %{organization: org} do + {:ok, agent_token, _token_string} = agent_token_fixture(org.id) + + {:ok, _enabled} = + Monitoring.create_check(valid_check_attrs(org.id, %{name: "Enabled", agent_token_id: agent_token.id})) + + {:ok, _disabled} = + Monitoring.create_check( + valid_check_attrs(org.id, %{name: "Disabled", enabled: false, agent_token_id: agent_token.id}) + ) + + checks = Monitoring.list_checks_for_agent(agent_token.id, enabled: true) + assert length(checks) == 1 + assert hd(checks).name == "Enabled" + end + + test "does not return checks for other agents", %{organization: org} do + {:ok, agent1, _} = agent_token_fixture(org.id) + {:ok, agent2, _} = agent_token_fixture(org.id) + + {:ok, _check} = + Monitoring.create_check(valid_check_attrs(org.id, %{name: "Agent 1 Check", agent_token_id: agent1.id})) + + assert Monitoring.list_checks_for_agent(agent2.id) == [] + end + end + describe "get_check/1 and get_check!/1" do test "returns the check", %{organization: org} do {:ok, check} = Monitoring.create_check(valid_check_attrs(org.id))