towerops/lib/towerops_web/live/check_live/form_component.ex
Graham McIntire 150ffb421d delegate service checks to agents and fix ui issues (#63)
- Skip HTTP/TCP/DNS checks in Phoenix when device is assigned to agent
- Agents now execute service checks from the device's network location
- Fix cancel button in service check modal (text link instead of button)
- Fix status badge alignment in checks table

Reviewed-on: graham/towerops-web#63
2026-03-17 15:43:31 -05:00

495 lines
15 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"}
]}
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)}
<% _ -> %>
<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-4">
<label class="flex items-center gap-2">
<.input field={@form[:verify_ssl]} type="checkbox" />
<span class="text-sm text-gray-700 dark:text-gray-300">Verify SSL Certificate</span>
</label>
<label class="flex items-center gap-2">
<.input field={@form[:follow_redirects]} type="checkbox" />
<span class="text-sm text-gray-700 dark:text-gray-300">Follow Redirects</span>
</label>
</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
@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 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 =
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, _type), do: %{}
defp assign_default_config(socket, type) do
config =
case type do
"http" ->
%{
"url" => "",
"method" => "GET",
"expected_status" => "200",
"verify_ssl" => "true",
"follow_redirects" => "true"
}
"tcp" ->
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"}
_ ->
%{}
end
assign(socket, :default_config, config)
end
defp merge_config_params(check_params, type) do
config =
case type do
"http" ->
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"]
)
"tcp" ->
%{
"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"])
"dns" ->
%{
"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
check_params
|> Map.put("config", config)
|> Map.put("check_type", type)
end
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