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