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
This commit is contained in:
Graham McIntire 2026-01-03 15:11:47 -06:00
parent 7772ff9a4a
commit d6a9bb11d7
No known key found for this signature in database
4 changed files with 502 additions and 161 deletions

View file

@ -107,6 +107,14 @@ defmodule Towerops.Snmp.Discovery do
{:ok, device} <- save_discovery_results(equipment, device_info, interfaces, sensors) do
update_equipment_discovery_time(equipment)
Logger.info("SNMP discovery completed successfully for: #{equipment.name}")
# Broadcast discovery completion for real-time updates
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:#{equipment.id}",
{:discovery_completed, equipment.id}
)
{:ok, device}
else
{:error, reason} = error ->

View file

@ -4,6 +4,7 @@ defmodule ToweropsWeb.EquipmentLive.Show do
alias Towerops.Equipment
alias Towerops.Monitoring
alias Towerops.Snmp
@impl true
def mount(_params, _session, socket) do
@ -12,32 +13,31 @@ defmodule ToweropsWeb.EquipmentLive.Show do
@impl true
def handle_params(%{"id" => id}, _, socket) do
equipment = Equipment.get_equipment!(id)
checks = Monitoring.list_equipment_checks(id, 10)
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{id}")
Process.send_after(self(), :refresh_data, 10_000)
end
# Subscribe to status updates for this equipment
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{id}")
end
{:noreply,
socket
|> assign(:page_title, equipment.name)
|> assign(:equipment, equipment)
|> assign(:recent_checks, checks)}
{:noreply, load_equipment_data(socket, id)}
end
@impl true
def handle_info({:equipment_status_changed, equipment_id, new_status, response_time}, socket) do
equipment = Equipment.get_equipment!(equipment_id)
checks = Monitoring.list_equipment_checks(equipment_id, 10)
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
|> assign(:equipment, equipment)
|> assign(:recent_checks, checks)
|> put_flash(:info, "Status updated: #{new_status} (#{response_time}ms)")}
|> load_equipment_data(socket.assigns.equipment.id)
|> put_flash(:info, "Discovery completed")}
end
@impl true
@ -59,4 +59,218 @@ defmodule ToweropsWeb.EquipmentLive.Show 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

View file

@ -3,157 +3,274 @@
current_organization={@current_organization}
active_page="equipment"
>
<.header>
{@page_title}
<:subtitle>{@equipment.ip_address}</:subtitle>
<:actions>
<.button phx-click="trigger_check" variant="primary">
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">{@equipment.name}</h1>
<p class="text-sm text-zinc-600 dark:text-zinc-400 font-mono">{@equipment.ip_address}</p>
</div>
<div class="flex gap-2">
<.button
phx-click="trigger_check"
class="text-sm bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
>
<.icon name="hero-arrow-path" class="h-4 w-4" /> Check Now
</.button>
<.button navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/edit"}>
<%= if @equipment.snmp_enabled do %>
<.button
phx-click="trigger_discovery"
class="text-sm bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
>
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Discover
</.button>
<% end %>
<.button
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/edit"}
class="text-sm bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
>
<.icon name="hero-pencil" class="h-4 w-4" /> Edit
</.button>
<.button
phx-click="delete"
data-confirm="Are you sure?"
class="bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 dark:bg-red-500 dark:hover:bg-red-600"
class="text-sm bg-red-600 text-white hover:bg-red-700"
>
<.icon name="hero-trash" class="h-4 w-4" /> Delete
</.button>
</:actions>
</.header>
<div class="mt-8 grid gap-6 md:grid-cols-2">
<div>
<h3 class="text-lg font-semibold mb-4 text-zinc-900 dark:text-zinc-100">
Equipment Details
</h3>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Status</dt>
<dd class="mt-1">
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
@equipment.status == :up &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
@equipment.status == :down &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
@equipment.status == :unknown &&
"bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
]}>
{@equipment.status |> to_string() |> String.upcase()}
</span>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Site</dt>
<dd class="mt-1">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{@equipment.site.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{@equipment.site.name}
</.link>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">IP Address</dt>
<dd class="mt-1 font-mono text-zinc-900 dark:text-zinc-100">{@equipment.ip_address}</dd>
</div>
<%= if @equipment.description do %>
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Description</dt>
<dd class="mt-1 text-zinc-900 dark:text-zinc-100">{@equipment.description}</dd>
</div>
<% end %>
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Monitoring</dt>
<dd class="mt-1">
<%= if @equipment.monitoring_enabled do %>
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-200">
Enabled
</span>
<% else %>
<span class="inline-flex items-center rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
Disabled
</span>
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Check Interval</dt>
<dd class="mt-1 text-zinc-900 dark:text-zinc-100">
{@equipment.check_interval_seconds} seconds
</dd>
</div>
</dl>
</div>
<div>
<h3 class="text-lg font-semibold mb-4 text-zinc-900 dark:text-zinc-100">
Monitoring History
</h3>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Last Checked</dt>
<dd class="mt-1 text-zinc-900 dark:text-zinc-100">
<%= if @equipment.last_checked_at do %>
{Calendar.strftime(@equipment.last_checked_at, "%Y-%m-%d %H:%M:%S UTC")}
<% else %>
<span class="text-zinc-500 dark:text-zinc-500">Never</span>
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Last Status Change</dt>
<dd class="mt-1 text-zinc-900 dark:text-zinc-100">
<%= if @equipment.last_status_change_at do %>
{Calendar.strftime(@equipment.last_status_change_at, "%Y-%m-%d %H:%M:%S UTC")}
<% else %>
<span class="text-zinc-500 dark:text-zinc-500">Never</span>
<% end %>
</dd>
</div>
</dl>
</div>
</div>
<!-- Status Overview Bar -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="bg-white dark:bg-zinc-800 rounded-lg p-3 border border-zinc-200 dark:border-zinc-700">
<div class="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Status</div>
<div class="flex items-center gap-2">
<span class={[
"inline-flex h-3 w-3 rounded-full",
@equipment.status == :up && "bg-green-500",
@equipment.status == :down && "bg-red-500",
@equipment.status == :unknown && "bg-zinc-400"
]}>
</span>
<span class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{@equipment.status |> to_string() |> String.upcase()}
</span>
</div>
</div>
<div class="mt-8">
<h3 class="text-lg font-semibold mb-4 text-zinc-900 dark:text-zinc-100">Recent Checks</h3>
<%= if Enum.empty?(@recent_checks) do %>
<div class="text-zinc-600 dark:text-zinc-400 text-center py-8">
<div class="bg-white dark:bg-zinc-800 rounded-lg p-3 border border-zinc-200 dark:border-zinc-700">
<div class="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Uptime</div>
<div class="text-lg font-bold text-zinc-900 dark:text-zinc-100">
{@metrics.uptime_percentage}%
</div>
</div>
<div class="bg-white dark:bg-zinc-800 rounded-lg p-3 border border-zinc-200 dark:border-zinc-700">
<div class="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Avg Response</div>
<div class="text-lg font-bold text-zinc-900 dark:text-zinc-100">
{@metrics.avg_response_time}ms
</div>
</div>
<div class="bg-white dark:bg-zinc-800 rounded-lg p-3 border border-zinc-200 dark:border-zinc-700">
<div class="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Last Check</div>
<div class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{time_ago(@equipment.last_checked_at)}
</div>
</div>
</div>
<!-- Graphs Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- Response Time Graph -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold mb-2 text-zinc-900 dark:text-zinc-100">Response Time</h3>
<div class="h-24">
{sparkline(@recent_checks, 400, 80)}
</div>
</div>
<!-- Uptime Gauge -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold mb-2 text-zinc-900 dark:text-zinc-100">
Availability (last {@metrics.total_checks} checks)
</h3>
<div class="mt-4">
{uptime_gauge(@metrics.uptime_percentage)}
</div>
<div class="mt-3 flex justify-between text-xs text-zinc-600 dark:text-zinc-400">
<span>{@metrics.successful_checks} successful</span>
<span>{@metrics.total_checks - @metrics.successful_checks} failed</span>
</div>
</div>
</div>
<%= if @snmp_device do %>
<!-- Device Info -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700 mb-4">
<h3 class="text-sm font-semibold mb-3 text-zinc-900 dark:text-zinc-100">
Device Information
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-2 text-sm">
<%= if @snmp_device.manufacturer do %>
<div>
<span class="text-zinc-600 dark:text-zinc-400">Manufacturer:</span>
<span class="ml-2 text-zinc-900 dark:text-zinc-100 font-medium">
{@snmp_device.manufacturer}
</span>
</div>
<% end %>
<%= if @snmp_device.model do %>
<div>
<span class="text-zinc-600 dark:text-zinc-400">Model:</span>
<span class="ml-2 text-zinc-900 dark:text-zinc-100 font-medium">
{@snmp_device.model}
</span>
</div>
<% end %>
<%= if @snmp_device.firmware_version do %>
<div>
<span class="text-zinc-600 dark:text-zinc-400">Firmware:</span>
<span class="ml-2 text-zinc-900 dark:text-zinc-100 font-medium">
{@snmp_device.firmware_version}
</span>
</div>
<% end %>
<%= if @equipment.last_discovery_at do %>
<div>
<span class="text-zinc-600 dark:text-zinc-400">Last Discovery:</span>
<span class="ml-2 text-zinc-900 dark:text-zinc-100 font-medium">
{Calendar.strftime(@equipment.last_discovery_at, "%Y-%m-%d %H:%M")}
</span>
</div>
<% end %>
</div>
<%= if @snmp_device.sys_descr do %>
<div class="mt-3 text-xs text-zinc-600 dark:text-zinc-400">
{@snmp_device.sys_descr}
</div>
<% end %>
</div>
<% end %>
<%= if @snmp_sensors && length(@snmp_sensors) > 0 do %>
<!-- Sensors -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700 mb-4">
<h3 class="text-sm font-semibold mb-3 text-zinc-900 dark:text-zinc-100">Sensors</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<%= for sensor <- @snmp_sensors do %>
<div class="bg-zinc-50 dark:bg-zinc-900 rounded p-2 border border-zinc-200 dark:border-zinc-700">
<div
class="text-xs text-zinc-600 dark:text-zinc-400 truncate"
title={sensor.sensor_descr}
>
{sensor.sensor_descr}
</div>
<%= if sensor.latest_reading do %>
<div class="mt-1 flex items-baseline gap-1">
<span class="text-lg font-bold text-zinc-900 dark:text-zinc-100">
{format_sensor_value(sensor.latest_reading.value, sensor.sensor_divisor)}
</span>
<span class="text-xs text-zinc-600 dark:text-zinc-400">{sensor.sensor_unit}</span>
</div>
<% else %>
<div class="mt-1 text-xs text-zinc-500 dark:text-zinc-500">No data</div>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>
<%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %>
<!-- Interfaces -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700 mb-4">
<h3 class="text-sm font-semibold mb-3 text-zinc-900 dark:text-zinc-100">
Network Interfaces
</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead>
<tr class="text-xs text-zinc-600 dark:text-zinc-400">
<th class="px-3 py-2 text-left font-medium">#</th>
<th class="px-3 py-2 text-left font-medium">Name</th>
<th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-left font-medium">Speed</th>
<th class="px-3 py-2 text-left font-medium">MAC</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
<%= for interface <- @snmp_interfaces do %>
<tr class="text-sm">
<td class="px-3 py-2 text-zinc-900 dark:text-zinc-100">{interface.if_index}</td>
<td class="px-3 py-2 text-zinc-900 dark:text-zinc-100">
{interface.if_name || interface.if_descr}
</td>
<td class="px-3 py-2">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
interface.if_oper_status == "up" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
interface.if_oper_status == "down" &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
interface.if_oper_status not in ["up", "down"] &&
"bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
]}>
{String.upcase(interface.if_oper_status || "unknown")}
</span>
</td>
<td class="px-3 py-2 text-zinc-900 dark:text-zinc-100">
{format_speed(interface.if_speed)}
</td>
<td class="px-3 py-2 font-mono text-xs text-zinc-600 dark:text-zinc-400">
{interface.if_phys_address || "-"}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
<!-- Recent Checks (Compact) -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold mb-3 text-zinc-900 dark:text-zinc-100">Recent Checks</h3>
<%= if Enum.empty?(Enum.take(@recent_checks, 10)) do %>
<div class="text-sm text-zinc-600 dark:text-zinc-400 text-center py-4">
No monitoring checks yet
</div>
<% else %>
<.table id="recent-checks" rows={@recent_checks}>
<:col :let={check} label="Status">
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
check.status == :success &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
check.status == :failure &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
]}>
{check.status |> to_string() |> String.upcase()}
</span>
</:col>
<:col :let={check} label="Response Time">
<%= if check.response_time_ms do %>
{check.response_time_ms}ms
<% else %>
<span class="text-zinc-500 dark:text-zinc-500">-</span>
<% end %>
</:col>
<:col :let={check} label="Checked At">
{Calendar.strftime(check.checked_at, "%Y-%m-%d %H:%M:%S UTC")}
</:col>
</.table>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead>
<tr class="text-xs text-zinc-600 dark:text-zinc-400">
<th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-left font-medium">Response</th>
<th class="px-3 py-2 text-left font-medium">Time</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
<%= for check <- Enum.take(@recent_checks, 10) do %>
<tr class="text-sm">
<td class="px-3 py-2">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
check.status == :success &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
check.status == :failure &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
]}>
{check.status |> to_string() |> String.upcase()}
</span>
</td>
<td class="px-3 py-2 text-zinc-900 dark:text-zinc-100">
<%= if check.response_time_ms do %>
{check.response_time_ms}ms
<% else %>
-
<% end %>
</td>
<td class="px-3 py-2 text-zinc-600 dark:text-zinc-400">
{Calendar.strftime(check.checked_at, "%H:%M:%S")}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</Layouts.authenticated>

View file

@ -79,25 +79,27 @@ defmodule ToweropsWeb.EquipmentLiveTest do
assert html =~ equipment.name
assert html =~ "192.168.1.1"
assert html =~ "Main router"
assert html =~ "Status"
assert html =~ "Uptime"
end
test "displays monitoring status", %{
test "displays monitoring metrics", %{
conn: conn,
organization: organization,
equipment: equipment
} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
assert html =~ "Monitoring"
assert html =~ "Enabled"
assert html =~ "300 seconds"
assert html =~ "Status"
assert html =~ "Uptime"
assert html =~ "Avg Response"
end
test "displays site link", %{conn: conn, organization: organization, equipment: equipment} do
test "displays dashboard widgets", %{conn: conn, organization: organization, equipment: equipment} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}")
assert html =~ "Test Site"
assert html =~ "Response Time"
assert html =~ "Availability"
end
test "displays recent checks", %{