- Create GraphLive.Show for detailed sensor graph visualization - Add route for /equipment/:id/graph/:sensor_type endpoint - Make all chart headers clickable with navigation to detail view - Implement date range selector (1h, 6h, 12h, 24h, 7d, 30d) - Fix chart rendering by destroying and recreating on data updates - Fix duplicate data loading in LiveView event handlers - Fix MikroTik profile typing warning for entity sensor discovery
503 lines
24 KiB
Text
503 lines
24 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_organization={@current_organization}
|
|
active_page="equipment"
|
|
>
|
|
<div class="mb-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<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 navigate={
|
|
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/edit"
|
|
}>
|
|
<.icon name="hero-pencil" class="h-4 w-4" /> Edit
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
<!-- Tabs -->
|
|
<div class="border-b border-zinc-200 dark:border-zinc-700">
|
|
<nav class="-mb-px flex space-x-8">
|
|
<.link
|
|
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=overview"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if @active_tab == "overview" do
|
|
"border-blue-500 text-blue-600 dark:text-blue-400"
|
|
else
|
|
"border-transparent text-zinc-500 hover:text-zinc-700 hover:border-zinc-300 dark:text-zinc-400 dark:hover:text-zinc-300"
|
|
end
|
|
]}
|
|
>
|
|
Overview
|
|
</.link>
|
|
|
|
<%= if @snmp_device do %>
|
|
<.link
|
|
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=ports"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if @active_tab == "ports" do
|
|
"border-blue-500 text-blue-600 dark:text-blue-400"
|
|
else
|
|
"border-transparent text-zinc-500 hover:text-zinc-700 hover:border-zinc-300 dark:text-zinc-400 dark:hover:text-zinc-300"
|
|
end
|
|
]}
|
|
>
|
|
Ports
|
|
</.link>
|
|
<% end %>
|
|
|
|
<.link
|
|
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=logs"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if @active_tab == "logs" do
|
|
"border-blue-500 text-blue-600 dark:text-blue-400"
|
|
else
|
|
"border-transparent text-zinc-500 hover:text-zinc-700 hover:border-zinc-300 dark:text-zinc-400 dark:hover:text-zinc-300"
|
|
end
|
|
]}
|
|
>
|
|
Logs
|
|
</.link>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
<!-- Tab Content -->
|
|
<div class="mt-6">
|
|
<%= case @active_tab do %>
|
|
<% "overview" -> %>
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
|
<!-- Device Information -->
|
|
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
Device Information
|
|
</h3>
|
|
</div>
|
|
<div class="p-4">
|
|
<dl class="space-y-3">
|
|
<%= if @snmp_device do %>
|
|
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">System Name</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{@snmp_device.sys_name || @equipment.name}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Resolved IP</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
|
|
{@equipment.ip_address}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Hardware</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{@snmp_device.model || "N/A"}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Operating System</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{if @snmp_device.manufacturer && @snmp_device.firmware_version do
|
|
"#{@snmp_device.manufacturer} #{@snmp_device.firmware_version}"
|
|
else
|
|
@snmp_device.sys_descr || "N/A"
|
|
end}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Object ID</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
|
|
{@snmp_device.sys_object_id || "N/A"}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Contact</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{@snmp_device.sys_contact || "N/A"}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Location</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{@snmp_device.sys_location || "N/A"}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Uptime</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{format_uptime(@snmp_device.sys_uptime)}
|
|
</dd>
|
|
</div>
|
|
<% else %>
|
|
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Name</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{@equipment.name}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">IP Address</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
|
|
{@equipment.ip_address}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Device Added</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{format_device_age(@equipment.inserted_at)}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-2">
|
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Last Discovered</dt>
|
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{if @equipment.last_discovery_at do
|
|
format_device_age(@equipment.last_discovery_at)
|
|
else
|
|
"Never"
|
|
end}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<!-- Graphs and Metrics -->
|
|
<div class="space-y-6">
|
|
<!-- CPU Usage Chart -->
|
|
<%= if @cpu_chart_data do %>
|
|
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
<.link
|
|
navigate={
|
|
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/processors"
|
|
}
|
|
class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
Processors (24 Hours)
|
|
</h3>
|
|
<.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
|
|
</div>
|
|
</.link>
|
|
<div class="p-4">
|
|
<div
|
|
id="cpu-chart"
|
|
phx-hook="SensorChart"
|
|
data-chart={@cpu_chart_data}
|
|
style="height: 300px;"
|
|
>
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- Memory Usage Chart -->
|
|
<%= if @memory_chart_data do %>
|
|
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
<.link
|
|
navigate={
|
|
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/memory"
|
|
}
|
|
class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
Memory Usage (24 Hours)
|
|
</h3>
|
|
<.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
|
|
</div>
|
|
</.link>
|
|
<div class="p-4">
|
|
<div
|
|
id="memory-chart"
|
|
phx-hook="SensorChart"
|
|
data-chart={@memory_chart_data}
|
|
style="height: 300px;"
|
|
>
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- Storage Usage Chart -->
|
|
<%= if @storage_chart_data do %>
|
|
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
<.link
|
|
navigate={
|
|
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/storage"
|
|
}
|
|
class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
Storage Usage (24 Hours)
|
|
</h3>
|
|
<.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
|
|
</div>
|
|
</.link>
|
|
<div class="p-4">
|
|
<div
|
|
id="storage-chart"
|
|
phx-hook="SensorChart"
|
|
data-chart={@storage_chart_data}
|
|
style="height: 300px;"
|
|
>
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- Temperature Chart -->
|
|
<%= if @temperature_chart_data do %>
|
|
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
<.link
|
|
navigate={
|
|
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/temperature"
|
|
}
|
|
class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
Temperature (24 Hours)
|
|
</h3>
|
|
<.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
|
|
</div>
|
|
</.link>
|
|
<div class="p-4">
|
|
<div
|
|
id="temperature-chart"
|
|
phx-hook="SensorChart"
|
|
data-chart={@temperature_chart_data}
|
|
data-unit="°C"
|
|
data-auto-scale="true"
|
|
style="height: 300px;"
|
|
>
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- Voltage Chart -->
|
|
<%= if @voltage_chart_data do %>
|
|
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
<.link
|
|
navigate={
|
|
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/voltage"
|
|
}
|
|
class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
Voltage (24 Hours)
|
|
</h3>
|
|
<.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
|
|
</div>
|
|
</.link>
|
|
<div class="p-4">
|
|
<div
|
|
id="voltage-chart"
|
|
phx-hook="SensorChart"
|
|
data-chart={@voltage_chart_data}
|
|
data-unit="V"
|
|
data-auto-scale="true"
|
|
style="height: 300px;"
|
|
>
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- Sensors -->
|
|
<%= if @snmp_sensors && length(@snmp_sensors) > 0 do %>
|
|
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Sensors</h3>
|
|
</div>
|
|
<div class="p-4">
|
|
<div class="grid grid-cols-2 md:grid-cols-3 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>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% "ports" -> %>
|
|
<%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %>
|
|
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
Network Interfaces
|
|
</h3>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
|
|
<thead class="bg-zinc-50 dark:bg-zinc-900">
|
|
<tr class="text-xs text-zinc-600 dark:text-zinc-400">
|
|
<th class="px-4 py-3 text-left font-medium">#</th>
|
|
<th class="px-4 py-3 text-left font-medium">Name</th>
|
|
<th class="px-4 py-3 text-left font-medium">Status</th>
|
|
<th class="px-4 py-3 text-left font-medium">Speed</th>
|
|
<th class="px-4 py-3 text-left font-medium">MAC</th>
|
|
<th class="px-4 py-3 text-left font-medium">Type</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
|
|
<%= for interface <- @snmp_interfaces do %>
|
|
<tr class="text-sm hover:bg-zinc-50 dark:hover:bg-zinc-900">
|
|
<td class="px-4 py-3 text-zinc-900 dark:text-zinc-100">
|
|
{interface.if_index}
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div class="font-medium text-zinc-900 dark:text-zinc-100">
|
|
{interface.if_name || interface.if_descr}
|
|
</div>
|
|
<%= if interface.if_alias do %>
|
|
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
|
{interface.if_alias}
|
|
</div>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<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-4 py-3 text-zinc-900 dark:text-zinc-100">
|
|
{format_speed(interface.if_speed)}
|
|
</td>
|
|
<td class="px-4 py-3 font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
|
{interface.if_phys_address || "-"}
|
|
</td>
|
|
<td class="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
|
|
{interface.if_type || "-"}
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="text-center py-12">
|
|
<p class="text-zinc-500 dark:text-zinc-400">No interfaces discovered</p>
|
|
</div>
|
|
<% end %>
|
|
<% "logs" -> %>
|
|
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Event Logs</h3>
|
|
<p class="text-xs text-zinc-600 dark:text-zinc-400 mt-1">
|
|
Showing the most recent 100 events
|
|
</p>
|
|
</div>
|
|
<%= if Enum.any?(@events) do %>
|
|
<div class="divide-y divide-zinc-200 dark:divide-zinc-700">
|
|
<%= for event <- @events do %>
|
|
<div class="px-4 py-3 hover:bg-zinc-50 dark:hover:bg-zinc-750">
|
|
<div class="flex items-start gap-3">
|
|
<!-- Severity Icon -->
|
|
<div class={[
|
|
"flex-shrink-0 mt-0.5",
|
|
event.severity == "info" && "text-blue-500",
|
|
event.severity == "warning" && "text-yellow-500",
|
|
event.severity == "critical" && "text-red-500"
|
|
]}>
|
|
<%= if event.severity == "info" do %>
|
|
<.icon name="hero-information-circle" class="w-5 h-5" />
|
|
<% end %>
|
|
<%= if event.severity == "warning" do %>
|
|
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
|
<% end %>
|
|
<%= if event.severity == "critical" do %>
|
|
<.icon name="hero-x-circle" class="w-5 h-5" />
|
|
<% end %>
|
|
</div>
|
|
<!-- Event Details -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{event.message}
|
|
</p>
|
|
<div class="mt-1 flex items-center gap-3 text-xs text-zinc-500 dark:text-zinc-400">
|
|
<span>{format_event_type(event.event_type)}</span>
|
|
<span>•</span>
|
|
<span class="capitalize">{event.severity}</span>
|
|
</div>
|
|
</div>
|
|
<time class="flex-shrink-0 text-xs text-zinc-500 dark:text-zinc-400">
|
|
{time_ago(event.occurred_at)}
|
|
</time>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% else %>
|
|
<div class="p-8 text-center">
|
|
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-zinc-400" />
|
|
<h3 class="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
No events yet
|
|
</h3>
|
|
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
|
Events will appear here as interface states change, speeds change, or other monitored changes occur.
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% _ -> %>
|
|
<div class="text-center py-12">
|
|
<p class="text-zinc-500 dark:text-zinc-400">Tab not found</p>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</Layouts.authenticated>
|