## Summary - Every device now automatically gets an ICMP ping check (`source_type: "auto_discovery"`) when created via `Monitoring.ensure_default_ping_check/1` - When a device IP changes, the ping check host config is synced automatically - Backfill migration inserts ICMP Ping checks for all existing devices that don't have one - Fixes checkbox alignment in the Add Service Check modal (verify SSL / follow redirects) ## Test plan - [x] 4 new tests for `ensure_default_ping_check/1` (create, idempotent, IP update, no-op) - [x] 3 new tests for device lifecycle (auto-create on device create, sync on IP change, no-op when IP unchanged) - [x] 13 existing tests updated to account for auto-created ping check - [x] Full test suite passes (`mix precommit`) - [ ] Verify backfill migration on staging - [ ] Create device in UI, confirm ping check appears in service checks list - [ ] Verify checkbox alignment in Add Service Check modal Reviewed-on: graham/towerops-web#68
539 lines
16 KiB
Elixir
539 lines
16 KiB
Elixir
defmodule ToweropsWeb.CheckLive.FormComponent do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_component
|
|
|
|
alias Towerops.Agents
|
|
alias Towerops.Monitoring
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<div class="relative z-50">
|
|
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity">
|
|
</div>
|
|
|
|
<div class="fixed inset-0 z-10 overflow-y-auto">
|
|
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
|
<div class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
|
<.form
|
|
for={@form}
|
|
id="check-form"
|
|
phx-target={@myself}
|
|
phx-change="validate"
|
|
phx-submit="save"
|
|
>
|
|
<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">
|
|
{if @action == :edit, do: "Edit Service Check", else: "Add Service Check"}
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
phx-click="close"
|
|
phx-target={@myself}
|
|
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
|
>
|
|
<.icon name="hero-x-mark" class="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<!-- Check Type Selection -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Check Type
|
|
</label>
|
|
<.input
|
|
field={@form[:check_type]}
|
|
type="select"
|
|
options={[
|
|
{"HTTP/HTTPS Check", "http"},
|
|
{"TCP Port Check", "tcp"},
|
|
{"DNS Check", "dns"},
|
|
{"SSL Certificate Check", "ssl"}
|
|
]}
|
|
phx-change="change_type"
|
|
phx-target={@myself}
|
|
disabled={@action == :edit}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Check Name -->
|
|
<div>
|
|
<.input
|
|
field={@form[:name]}
|
|
type="text"
|
|
label="Check Name"
|
|
placeholder="e.g., Web Server Check"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Dynamic fields based on check type -->
|
|
<%= case @selected_type do %>
|
|
<% "http" -> %>
|
|
{render_http_fields(assigns)}
|
|
<% "tcp" -> %>
|
|
{render_tcp_fields(assigns)}
|
|
<% "dns" -> %>
|
|
{render_dns_fields(assigns)}
|
|
<% "ssl" -> %>
|
|
{render_ssl_fields(assigns)}
|
|
<% _ -> %>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
Select a check type to configure
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Common Settings -->
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<.input
|
|
field={@form[:interval_seconds]}
|
|
type="number"
|
|
label="Interval (seconds)"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<.input field={@form[:timeout_ms]} type="number" label="Timeout (ms)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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-3">
|
|
<.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"
|
|
phx-click="close"
|
|
phx-target={@myself}
|
|
class="text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp render_http_fields(assigns) do
|
|
~H"""
|
|
<div class="space-y-4">
|
|
<div>
|
|
<.input field={@form[:url]} type="text" label="URL" placeholder="https://example.com/health" />
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<.input
|
|
field={@form[:method]}
|
|
type="select"
|
|
label="Method"
|
|
options={["GET", "POST", "PUT", "DELETE", "HEAD"]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<.input
|
|
field={@form[:expected_status]}
|
|
type="number"
|
|
label="Expected Status"
|
|
placeholder="200"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-6">
|
|
<.input field={@form[:verify_ssl]} type="checkbox" label="Verify SSL Certificate" />
|
|
<.input field={@form[:follow_redirects]} type="checkbox" label="Follow Redirects" />
|
|
</div>
|
|
|
|
<details class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
|
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Advanced Options
|
|
</summary>
|
|
<div class="mt-3 space-y-3">
|
|
<div>
|
|
<.input
|
|
field={@form[:content_match]}
|
|
type="text"
|
|
label="Content Match (regex)"
|
|
placeholder="Optional regex pattern"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp render_tcp_fields(assigns) do
|
|
~H"""
|
|
<div class="space-y-4">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<.input field={@form[:host]} type="text" label="Host" placeholder="example.com" />
|
|
</div>
|
|
<div>
|
|
<.input field={@form[:port]} type="number" label="Port" placeholder="80" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<.input
|
|
field={@form[:send_string]}
|
|
type="text"
|
|
label="Send String (optional)"
|
|
placeholder="Text to send after connecting"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<.input
|
|
field={@form[:expect_string]}
|
|
type="text"
|
|
label="Expect String (optional)"
|
|
placeholder="Expected response pattern"
|
|
/>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp render_dns_fields(assigns) do
|
|
~H"""
|
|
<div class="space-y-4">
|
|
<div>
|
|
<.input field={@form[:hostname]} type="text" label="Hostname" placeholder="example.com" />
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<.input
|
|
field={@form[:record_type]}
|
|
type="select"
|
|
label="Record Type"
|
|
options={["A", "AAAA", "CNAME", "MX", "TXT", "NS"]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<.input
|
|
field={@form[:dns_server]}
|
|
type="text"
|
|
label="DNS Server (optional)"
|
|
placeholder="8.8.8.8"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<.input
|
|
field={@form[:expected_result]}
|
|
type="text"
|
|
label="Expected Result (optional)"
|
|
placeholder="Expected IP or value"
|
|
/>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp render_ssl_fields(assigns) do
|
|
~H"""
|
|
<div class="space-y-4">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<.input field={@form[:host]} type="text" label="Host" placeholder="example.com" />
|
|
</div>
|
|
<div>
|
|
<.input field={@form[:port]} type="number" label="Port" placeholder="443" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<.input
|
|
field={@form[:warning_days]}
|
|
type="number"
|
|
label="Warning Days"
|
|
placeholder="30"
|
|
/>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
Alert when certificate expires within this many days
|
|
</p>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
@impl true
|
|
def mount(socket) do
|
|
{:ok, socket}
|
|
end
|
|
|
|
@impl true
|
|
def update(assigns, socket) do
|
|
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(:action, action)
|
|
|> assign(:selected_type, selected_type)
|
|
|> assign(:form, to_form(changeset))
|
|
|> assign_default_config(selected_type)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("change_type", %{"check" => %{"check_type" => type}}, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:selected_type, type)
|
|
|> assign_default_config(type)
|
|
|
|
# Auto-fill host from device IP for TCP and SSL checks
|
|
socket =
|
|
if type in ["tcp", "ssl"] && 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 =
|
|
check
|
|
|> Monitoring.change_check(check_params)
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign(socket, form: to_form(changeset))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save", %{"check" => check_params}, socket) do
|
|
check_params = merge_config_params(check_params, socket.assigns.selected_type)
|
|
|
|
if socket.assigns.action == :edit do
|
|
save_edit(socket, check_params)
|
|
else
|
|
save_new(socket, check_params)
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("close", _params, socket) do
|
|
notify_parent(:close)
|
|
{: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, "ssl") do
|
|
%{
|
|
"host" => config["host"] || "",
|
|
"port" => to_string(config["port"] || 443),
|
|
"warning_days" => to_string(config["warning_days"] || 30)
|
|
}
|
|
end
|
|
|
|
defp config_to_form_fields(_config, _type), do: %{}
|
|
|
|
defp assign_default_config(socket, type) do
|
|
assign(socket, :default_config, default_config_for_type(socket, type))
|
|
end
|
|
|
|
defp default_config_for_type(_socket, "http") do
|
|
%{
|
|
"url" => "",
|
|
"method" => "GET",
|
|
"expected_status" => "200",
|
|
"verify_ssl" => "true",
|
|
"follow_redirects" => "true"
|
|
}
|
|
end
|
|
|
|
defp default_config_for_type(socket, "tcp") do
|
|
%{"host" => device_host(socket), "port" => ""}
|
|
end
|
|
|
|
defp default_config_for_type(_socket, "dns") do
|
|
%{"hostname" => "", "record_type" => "A"}
|
|
end
|
|
|
|
defp default_config_for_type(socket, "ssl") do
|
|
%{"host" => device_host(socket), "port" => "443", "warning_days" => "30"}
|
|
end
|
|
|
|
defp default_config_for_type(_socket, _type), do: %{}
|
|
|
|
defp device_host(socket) do
|
|
device = socket.assigns[:device]
|
|
if device && device.ip_address, do: to_string(device.ip_address), else: ""
|
|
end
|
|
|
|
defp merge_config_params(check_params, type) do
|
|
check_params
|
|
|> Map.put("config", config_from_params(check_params, type))
|
|
|> Map.put("check_type", type)
|
|
end
|
|
|
|
defp config_from_params(check_params, "http") do
|
|
maybe_add(
|
|
%{
|
|
"url" => check_params["url"],
|
|
"method" => check_params["method"] || "GET",
|
|
"expected_status" => String.to_integer(check_params["expected_status"] || "200"),
|
|
"verify_ssl" => check_params["verify_ssl"] == "true",
|
|
"follow_redirects" => check_params["follow_redirects"] == "true"
|
|
},
|
|
"regex",
|
|
check_params["content_match"]
|
|
)
|
|
end
|
|
|
|
defp config_from_params(check_params, "tcp") do
|
|
%{
|
|
"host" => check_params["host"],
|
|
"port" => String.to_integer(check_params["port"] || "0")
|
|
}
|
|
|> maybe_add("send", check_params["send_string"])
|
|
|> maybe_add("expect", check_params["expect_string"])
|
|
end
|
|
|
|
defp config_from_params(check_params, "dns") do
|
|
%{
|
|
"hostname" => check_params["hostname"],
|
|
"record_type" => check_params["record_type"] || "A"
|
|
}
|
|
|> maybe_add("server", check_params["dns_server"])
|
|
|> maybe_add("expected", check_params["expected_result"])
|
|
end
|
|
|
|
defp config_from_params(check_params, "ssl") do
|
|
%{
|
|
"host" => check_params["host"],
|
|
"port" => String.to_integer(check_params["port"] || "443"),
|
|
"warning_days" => String.to_integer(check_params["warning_days"] || "30")
|
|
}
|
|
end
|
|
|
|
defp config_from_params(_check_params, _type), do: %{}
|
|
|
|
defp maybe_add(map, _key, nil), do: map
|
|
defp maybe_add(map, _key, ""), do: map
|
|
defp maybe_add(map, key, value), do: Map.put(map, key, value)
|
|
|
|
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
|
end
|