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: graham/towerops-web#50
This commit is contained in:
parent
06c5d14455
commit
c6913685ee
9 changed files with 715 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Add Service Check
|
||||
{if @action == :edit, do: "Edit Service Check", else: "Add Service Check"}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -52,6 +53,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
]}
|
||||
phx-change="change_type"
|
||||
phx-target={@myself}
|
||||
disabled={@action == :edit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -96,8 +98,11 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
|
||||
<.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>
|
||||
<.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"}
|
||||
|
|
|
|||
|
|
@ -587,7 +587,7 @@
|
|||
<:col :let={discovered} label={t("Type")}>
|
||||
<div class="flex items-center gap-2">
|
||||
<.icon
|
||||
name={device_type_icon(discovered.device_type)}
|
||||
name="hero-server"
|
||||
class="h-4 w-4 text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2720,14 +2720,35 @@
|
|||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
|
||||
<.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>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<.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>
|
||||
<%= if check.check_type in ["http", "tcp", "dns"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="edit_check"
|
||||
phx-value-id={check.id}
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<.icon name="hero-pencil-square" class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete_check"
|
||||
phx-value-id={check.id}
|
||||
data-confirm="Are you sure you want to delete this check?"
|
||||
class="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" />
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% 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 %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue