towerops/lib/towerops_web/live/check_live/form_component.ex
Graham McIntire c8a092196f add auto ICMP ping check for all devices (#68)
## 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
2026-03-17 17:55:23 -05:00

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