towerops/lib/towerops_web/live/equipment_live/show.html.heex
Graham McIntire ddabb3f030
Add detailed graph view with date range selection
- 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
2026-01-05 11:09:21 -06:00

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>