towerops/lib/towerops_web/live/equipment_live/show.ex
Graham McIntire d6a9bb11d7
Redesign equipment view with compact LibreNMS-style dashboard
- Add real-time updates with 10-second refresh and PubSub integration
- Implement pure SVG charts: response time sparkline and uptime gauge
- Create compact dashboard layout with status cards, metrics, and graphs
- Add relative time display (e.g., '5s ago') for last check
- Redesign SNMP data display: compact device info, sensors grid, interfaces table
- Calculate and display uptime percentage and average response time
- Update tests to match new compact layout
2026-01-03 15:11:47 -06:00

276 lines
7.8 KiB
Elixir

defmodule ToweropsWeb.EquipmentLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Equipment
alias Towerops.Monitoring
alias Towerops.Snmp
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{id}")
Process.send_after(self(), :refresh_data, 10_000)
end
{:noreply, load_equipment_data(socket, id)}
end
@impl true
def handle_info(:refresh_data, socket) do
Process.send_after(self(), :refresh_data, 10_000)
{:noreply, load_equipment_data(socket, socket.assigns.equipment.id)}
end
@impl true
def handle_info({:equipment_status_changed, _equipment_id, _new_status, _response_time}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.equipment.id)}
end
@impl true
def handle_info({:discovery_completed, _equipment_id}, socket) do
{:noreply,
socket
|> load_equipment_data(socket.assigns.equipment.id)
|> put_flash(:info, "Discovery completed")}
end
@impl true
def handle_event("delete", _params, socket) do
case Equipment.delete_equipment(socket.assigns.equipment) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Equipment deleted successfully")
|> push_navigate(to: ~p"/orgs/#{socket.assigns.current_organization.slug}/equipment")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Unable to delete equipment")}
end
end
@impl true
def handle_event("trigger_check", _params, socket) do
_ = Towerops.Monitoring.EquipmentMonitor.trigger_check(socket.assigns.equipment.id)
{:noreply, put_flash(socket, :info, "Check triggered")}
end
@impl true
def handle_event("trigger_discovery", _params, socket) do
equipment = socket.assigns.equipment
if equipment.snmp_enabled do
# Run discovery in background
Task.start(fn ->
Snmp.discover_equipment(equipment)
end)
{:noreply, put_flash(socket, :info, "Discovery started...")}
else
{:noreply, put_flash(socket, :error, "SNMP is not enabled for this equipment")}
end
end
# Private functions
defp load_equipment_data(socket, equipment_id) do
equipment = Equipment.get_equipment!(equipment_id)
recent_checks = Monitoring.list_equipment_checks(equipment_id, 50)
snmp_data = load_snmp_data(equipment_id)
# Calculate metrics for dashboard
metrics = calculate_metrics(recent_checks, equipment)
socket
|> assign(:page_title, equipment.name)
|> assign(:equipment, equipment)
|> assign(:recent_checks, recent_checks)
|> assign(:metrics, metrics)
|> assign(:snmp_device, snmp_data.device)
|> assign(:snmp_interfaces, snmp_data.interfaces)
|> assign(:snmp_sensors, snmp_data.sensors)
end
defp calculate_metrics(checks, _equipment) do
total_checks = length(checks)
if total_checks > 0 do
successful_checks = Enum.count(checks, &(&1.status == :success))
uptime_percentage = Float.round(successful_checks / total_checks * 100, 1)
avg_response_time =
checks
|> Enum.filter(&(&1.response_time_ms != nil))
|> case do
[] ->
0
valid_checks ->
sum = Enum.reduce(valid_checks, 0, &(&1.response_time_ms + &2))
Float.round(sum / length(valid_checks), 1)
end
%{
uptime_percentage: uptime_percentage,
avg_response_time: avg_response_time,
total_checks: total_checks,
successful_checks: successful_checks
}
else
%{
uptime_percentage: 0,
avg_response_time: 0,
total_checks: 0,
successful_checks: 0
}
end
end
defp format_speed(speed_bps) when is_integer(speed_bps) do
cond do
speed_bps >= 1_000_000_000 ->
"#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps"
speed_bps >= 1_000_000 ->
"#{Float.round(speed_bps / 1_000_000, 1)} Mbps"
speed_bps >= 1_000 ->
"#{Float.round(speed_bps / 1_000, 1)} Kbps"
true ->
"#{speed_bps} bps"
end
end
defp format_speed(_), do: "-"
defp format_sensor_value(value, divisor) when is_number(value) and is_number(divisor) do
result = value / divisor
Float.round(result, 1)
end
defp format_sensor_value(_, _), do: "-"
defp time_ago(nil), do: "Never"
defp time_ago(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
diff < 60 -> "#{diff}s ago"
diff < 3600 -> "#{div(diff, 60)}m ago"
diff < 86_400 -> "#{div(diff, 3600)}h ago"
true -> "#{div(diff, 86_400)}d ago"
end
end
defp load_snmp_data(equipment_id) do
case Snmp.get_device_with_associations(equipment_id) do
nil ->
%{device: nil, interfaces: [], sensors: []}
device ->
# Preload latest readings for sensors
sensors_with_readings =
Enum.map(device.sensors, fn sensor ->
latest_reading = Snmp.get_latest_sensor_reading(sensor.id)
Map.put(sensor, :latest_reading, latest_reading)
end)
# Preload latest stats for interfaces
interfaces_with_stats =
Enum.map(device.interfaces, fn interface ->
latest_stat = Snmp.get_latest_interface_stat(interface.id)
Map.put(interface, :latest_stat, latest_stat)
end)
%{
device: device,
interfaces: interfaces_with_stats,
sensors: sensors_with_readings
}
end
end
# SVG Chart helpers
defp sparkline(checks, width, height) when is_list(checks) do
if Enum.empty?(checks) or length(checks) < 2 do
""
else
# Get response times in reverse order (oldest to newest for left-to-right)
data =
checks
|> Enum.reverse()
|> Enum.map(fn check ->
if check.response_time_ms, do: check.response_time_ms, else: 0
end)
max_value = Enum.max(data, fn -> 1 end)
max_value = if max_value == 0, do: 1, else: max_value
data_count = length(data)
points =
data
|> Enum.with_index()
|> Enum.map_join(" ", fn {value, index} ->
x = index * (width / (data_count - 1))
y = height - value / max_value * height
"#{Float.round(x, 2)},#{Float.round(y, 2)}"
end)
assigns = %{points: points, width: width, height: height, data: data}
~H"""
<svg viewBox={"0 0 #{@width} #{@height}"} class="w-full h-full">
<polyline
points={@points}
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-blue-500"
/>
<%= for {value, idx} <- Enum.with_index(@data) do %>
<circle
cx={idx * (@width / (length(@data) - 1))}
cy={@height - value / Enum.max(@data, fn -> 1 end) * @height}
r="2"
class={if value == 0, do: "fill-red-500", else: "fill-blue-500"}
/>
<% end %>
</svg>
"""
end
end
defp uptime_gauge(percentage) do
assigns = %{percentage: percentage}
color_class =
cond do
percentage >= 95 -> "bg-green-500"
percentage >= 80 -> "bg-yellow-500"
true -> "bg-red-500"
end
assigns = Map.put(assigns, :color_class, color_class)
~H"""
<div class="relative w-full h-6 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div
class={"absolute inset-y-0 left-0 #{@color_class} transition-all duration-500"}
style={"width: #{@percentage}%"}
>
</div>
<div class="absolute inset-0 flex items-center justify-center text-xs font-medium text-zinc-900 dark:text-zinc-100">
{@percentage}%
</div>
</div>
"""
end
end