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:
parent
7772ff9a4a
commit
d6a9bb11d7
4 changed files with 502 additions and 161 deletions
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", %{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue