towerops/lib/towerops_web/live/device_live/components/checks_tab.ex
Graham McIntire c4e301a84c fix: address code review findings - memory leaks and redundant queries (#192)
Fixed 5 critical issues from code review:

1. **GlobalSearchTrigger hook listener leak**
   - Added destroyed() callback to remove click listener
   - Prevents memory leak on LiveView unmount

2. **NetworkMap/WeathermapViewer hook listener leaks**
   - Store zoom/fit button handlers as properties
   - Remove DOM event listeners in destroyed() callback
   - Fixes leak on every LiveView remount

3. **SitesMap uses undocumented window.liveSocket.execJS**
   - Changed to proper LiveView pattern using pushEvent
   - Attach listener via Leaflet's popupopen event

4. **handle_info(:refresh_data) double DB fetch**
   - Removed redundant Devices.get_device call
   - Reuse device from assign_base_data

5. **Redundant DB queries in DeviceLive.Show overview**
   - Changed load_sensor_chart_data to accept snmp_device
   - Changed load_overall_traffic_chart_data to accept snmp_device
   - Eliminated 4 redundant Snmp.get_device_with_associations calls

Also removed unused functions:
- load_equipment_data/2
- reload_active_tab_data/1
- assign_subscriber_impact/1

Note: DeviceLive.Show module size (2252 lines) is acknowledged but
deferred as it requires larger architectural refactoring.

Reviewed-on: graham/towerops-web#192
2026-03-27 16:34:42 -05:00

292 lines
12 KiB
Elixir

defmodule ToweropsWeb.DeviceLive.Components.ChecksTab do
@moduledoc """
LiveComponent for the device monitoring checks tab.
Displays all monitoring checks for the device grouped by type
(SNMP, HTTP, TCP, DNS, SSL, ping).
"""
use ToweropsWeb, :live_component
alias Towerops.Monitoring
alias ToweropsWeb.DeviceLive.Helpers.DataLoaders
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> load_checks_data()}
end
defp load_checks_data(socket) do
device_id = socket.assigns.device.id
organization_id = socket.assigns.device.organization_id
checks_data = DataLoaders.load_checks_data(device_id, organization_id)
socket
|> assign(:checks, checks_data.checks)
|> assign(:grouped_checks, checks_data.grouped_checks)
end
@impl true
def render(assigns) do
~H"""
<div id="checks-tab">
<%= if Enum.empty?(@checks) do %>
<!-- Empty State -->
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-12 text-center">
<.icon name="hero-clipboard-document-check" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{t("No checks configured")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t(
"Run discovery to automatically detect sensors, interfaces, and other monitorable items, or add a service check manually."
)}
</p>
<div class="mt-6 flex justify-center gap-3">
<%= if @device.snmp_enabled do %>
<.button type="button" phx-click="run_discovery">
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Run Discovery
</.button>
<% end %>
<.button type="button" phx-click="add_check">
<.icon name="hero-plus" class="h-4 w-4" /> Add Check
</.button>
</div>
</div>
</div>
<% else %>
<!-- Checks List -->
<div class="space-y-6">
<!-- Header with count and Add button -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Checks ({length(@checks)})
</h2>
<.button type="button" phx-click="add_check">
<.icon name="hero-plus" class="h-4 w-4" /> Add Check
</.button>
</div>
<%= for {group_key, group_checks} <- @grouped_checks do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<!-- Group Header -->
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-gray-900/50">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{group_title(group_key)} ({length(group_checks)})
</h3>
</div>
<!-- Checks Table -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-900/30">
<tr>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
{t("Name")}
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
{t("Status")}
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
{t("Value")}
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
{t("Last Checked")}
</th>
<th scope="col" class="relative px-4 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for check <- group_checks do %>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/30">
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{check.name}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-left">
{render_status_badge(check.current_state)}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<%= if check.last_check_at do %>
{get_latest_value(check)}
<% else %>
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
Pending
</span>
<% end %>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<%= if check.last_check_at do %>
{format_relative_time(check.last_check_at)}
<% else %>
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
Never
</span>
<% end %>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
<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", "ssl"] 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 %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
"""
end
# Helper functions
defp group_title(:snmp_sensors), do: "SNMP Sensors"
defp group_title(:snmp_interfaces), do: "SNMP Interfaces"
defp group_title(:snmp_processors), do: "SNMP Processors"
defp group_title(:snmp_storage), do: "SNMP Storage"
defp group_title(:http_checks), do: "HTTP Checks"
defp group_title(:tcp_checks), do: "TCP Checks"
defp group_title(:dns_checks), do: "DNS Checks"
defp group_title(:ssl_checks), do: "SSL Certificate Checks"
defp group_title(:ping_checks), do: "Ping Checks"
defp group_title(:other_checks), do: "Other Checks"
defp render_status_badge(0) do
assigns = %{}
~H"""
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
OK
</span>
"""
end
defp render_status_badge(1) do
assigns = %{}
~H"""
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
WARNING
</span>
"""
end
defp render_status_badge(2) do
assigns = %{}
~H"""
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
CRITICAL
</span>
"""
end
defp render_status_badge(3) do
assigns = %{}
~H"""
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
UNKNOWN
</span>
"""
end
defp render_status_badge(_) do
assigns = %{}
~H"""
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
PENDING
</span>
"""
end
defp get_latest_value(check) do
# Get the latest check result to show current value
case Monitoring.get_latest_check_result(check.id) do
nil -> "-"
result -> format_check_value(result, check)
end
end
defp format_check_value(%{value: nil}, _check), do: "-"
defp format_check_value(%{value: value}, check) when is_number(value) do
case check.check_type do
"snmp_sensor" -> format_check_sensor_value(value, check.config)
"snmp_processor" -> "#{Float.round(value, 1)}%"
"snmp_storage" -> "#{Float.round(value, 1)}%"
t when t in ["ping", "http", "tcp", "dns"] -> "#{Float.round(value, 2)} ms"
_ -> value |> Float.round(2) |> to_string()
end
end
defp format_check_value(_result, _check), do: "-"
@sensor_value_formats %{
"temperature" => {1, "°C"},
"voltage" => {2, "V"},
"current" => {2, "A"},
"power" => {1, "W"},
"frequency" => {0, "Hz"},
"humidity" => {1, "%"}
}
defp format_check_sensor_value(value, %{"sensor_type" => "fanspeed", "sensor_unit" => unit}) do
"#{round(value)}#{unit || " RPM"}"
end
defp format_check_sensor_value(value, config) do
sensor_type = config["sensor_type"]
unit = config["sensor_unit"]
{precision, default_unit} = Map.get(@sensor_value_formats, sensor_type, {2, ""})
"#{Float.round(value, precision)}#{unit || default_unit}"
end
defp format_relative_time(datetime) do
ToweropsWeb.TimeHelpers.format_time_ago(datetime)
end
end