gosnmp returns OIDs with leading dots (e.g., ".1.3.6.1.2.1.1.1.0") but the Replay adapter did exact-match lookups without the dot. Walks already normalized both sides, so interfaces worked. GETs did not, breaking all system OID resolution (sysDescr, sysName, etc).
2038 lines
103 KiB
Text
2038 lines
103 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="devices"
|
|
>
|
|
<div class="mb-6 -mt-2">
|
|
<!-- Breadcrumbs -->
|
|
<nav :if={@device.site} class="flex mb-4" aria-label="Breadcrumb">
|
|
<ol class="inline-flex items-center space-x-1 text-sm text-gray-600 dark:text-gray-400">
|
|
<li class="inline-flex items-center">
|
|
<.link
|
|
navigate={~p"/sites/#{@device.site.id}"}
|
|
class="hover:text-gray-900 dark:hover:text-white"
|
|
>
|
|
{@device.site.name}
|
|
</.link>
|
|
</li>
|
|
<li aria-current="page">
|
|
<div class="flex items-center">
|
|
<.icon name="hero-chevron-right" class="h-4 w-4 mx-1" />
|
|
<span class="text-gray-900 dark:text-white font-medium">{@device.name}</span>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<div class="flex items-center gap-3">
|
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@device.name}</h1>
|
|
<div class={[
|
|
"flex items-center gap-2 px-3 py-1.5 rounded-lg font-semibold text-sm transition-all",
|
|
case @device.status do
|
|
:up ->
|
|
"bg-green-100 text-green-800 dark:bg-green-950/50 dark:text-green-400 ring-1 ring-green-600/20 dark:ring-green-500/30"
|
|
|
|
:down ->
|
|
"bg-red-100 text-red-800 dark:bg-red-950/50 dark:text-red-400 ring-1 ring-red-600/20 dark:ring-red-500/30"
|
|
|
|
:unknown ->
|
|
"bg-gray-100 text-gray-800 dark:bg-gray-800/50 dark:text-gray-400 ring-1 ring-gray-600/20 dark:ring-gray-500/30"
|
|
end
|
|
]}>
|
|
<span class={[
|
|
"h-2 w-2 rounded-full animate-pulse",
|
|
case @device.status do
|
|
:up -> "bg-green-600 dark:bg-green-500"
|
|
:down -> "bg-red-600 dark:bg-red-500"
|
|
:unknown -> "bg-gray-500 dark:bg-gray-400"
|
|
end
|
|
]}>
|
|
</span>
|
|
<span>
|
|
{case @device.status do
|
|
:up -> "Online"
|
|
:down -> "Offline"
|
|
:unknown -> "Unknown"
|
|
end}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3 mt-0.5">
|
|
<span class="text-sm text-gray-600 dark:text-gray-400 font-mono">
|
|
{@device.ip_address}
|
|
</span>
|
|
<span class="text-gray-300 dark:text-gray-600">•</span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
Polled by:
|
|
</span>
|
|
<%= if @agent_info.type == :cloud do %>
|
|
<span class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
|
<.icon name="hero-cloud" class="h-3.5 w-3.5" />
|
|
<%= if @agent_info.agent_token_id do %>
|
|
Cloud ({@agent_info.name})
|
|
<% else %>
|
|
Cloud
|
|
<% end %>
|
|
</span>
|
|
<% else %>
|
|
<span class="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
|
<.icon name="hero-server" class="h-3.5 w-3.5" />
|
|
{@agent_info.name}
|
|
</span>
|
|
<%= if @agent_info.is_offline do %>
|
|
<span
|
|
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-amber-700 bg-amber-100 dark:text-amber-400 dark:bg-amber-900/30 rounded-md"
|
|
title="Agent has not checked in for over 5 minutes. This device will not be polled until the agent comes back online."
|
|
>
|
|
<.icon name="hero-exclamation-triangle" class="h-3 w-3" /> Offline
|
|
</span>
|
|
<% end %>
|
|
<%= if @agent_info.last_seen_at do %>
|
|
<span class="text-xs text-gray-400 dark:text-gray-500">
|
|
({format_relative_time(@agent_info.last_seen_at)})
|
|
</span>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<.button navigate={~p"/devices/#{@device.id}/edit"}>
|
|
<.icon name="hero-pencil" class="h-4 w-4" /> Edit
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
<!-- Tabs -->
|
|
<div class="border-b border-gray-200 dark:border-white/10">
|
|
<nav class="-mb-px flex space-x-8">
|
|
<.link
|
|
patch={~p"/devices/#{@device.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-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
end
|
|
]}
|
|
>
|
|
Overview
|
|
</.link>
|
|
|
|
<%= if @snmp_device do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.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-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
end
|
|
]}
|
|
>
|
|
Ports
|
|
</.link>
|
|
<% end %>
|
|
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=neighbors"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if @active_tab == "neighbors" do
|
|
"border-blue-500 text-blue-600 dark:text-blue-400"
|
|
else
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
end
|
|
]}
|
|
>
|
|
Neighbors
|
|
</.link>
|
|
|
|
<%= if @arp_entries && length(@arp_entries) > 0 do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=arp"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if @active_tab == "arp" do
|
|
"border-blue-500 text-blue-600 dark:text-blue-400"
|
|
else
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
end
|
|
]}
|
|
>
|
|
ARP Table
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%= if @mac_addresses && length(@mac_addresses) > 0 do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=mac"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if @active_tab == "mac" do
|
|
"border-blue-500 text-blue-600 dark:text-blue-400"
|
|
else
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
end
|
|
]}
|
|
>
|
|
MAC Table
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%= if @vlans && length(@vlans) > 0 do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=vlans"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if @active_tab == "vlans" do
|
|
"border-blue-500 text-blue-600 dark:text-blue-400"
|
|
else
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
end
|
|
]}
|
|
>
|
|
VLANs
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%= if length(@ipv4_addresses) > 0 || length(@ipv6_addresses) > 0 do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=ip_addresses"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if @active_tab == "ip_addresses" do
|
|
"border-blue-500 text-blue-600 dark:text-blue-400"
|
|
else
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
end
|
|
]}
|
|
>
|
|
IP Addresses
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%= if @device.mikrotik_enabled do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=backups"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if @active_tab == "backups" do
|
|
"border-blue-500 text-blue-600 dark:text-blue-400"
|
|
else
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
end
|
|
]}
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-beaker" class="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" />
|
|
Backups
|
|
</span>
|
|
</.link>
|
|
<% end %>
|
|
|
|
<.link
|
|
patch={~p"/devices/#{@device.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-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
end
|
|
]}
|
|
>
|
|
Events
|
|
</.link>
|
|
|
|
<%= if @snmp_device && @snmp_device.raw_discovery_data do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=debug"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if @active_tab == "debug" do
|
|
"border-blue-500 text-blue-600 dark:text-blue-400"
|
|
else
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
end
|
|
]}
|
|
>
|
|
Raw Data
|
|
</.link>
|
|
<% end %>
|
|
</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">
|
|
<!-- Left Column: Device Information & Traffic -->
|
|
<div class="space-y-6">
|
|
<!-- Device Information -->
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
Device Information
|
|
</h3>
|
|
</div>
|
|
<div class="p-4">
|
|
<dl class="space-y-1">
|
|
<%= if @snmp_device do %>
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">System Name</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{@snmp_device.sys_name || @device.name}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Resolved IP</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono">
|
|
{@device.ip_address}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Hardware</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{@snmp_device.model || "N/A"}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Operating System</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
<div class="flex items-center gap-2">
|
|
<span>
|
|
{cond do
|
|
@snmp_device.manufacturer && @snmp_device.firmware_version ->
|
|
"#{@snmp_device.manufacturer} #{@snmp_device.firmware_version}"
|
|
|
|
@snmp_device.manufacturer ->
|
|
@snmp_device.manufacturer
|
|
|
|
true ->
|
|
@snmp_device.sys_descr || "N/A"
|
|
end}
|
|
</span>
|
|
<%= if firmware_update_available?(@snmp_device, @available_firmware) do %>
|
|
<div class="group/firmware relative inline-flex items-center">
|
|
<.icon
|
|
name="hero-arrow-up-circle"
|
|
class="h-5 w-5 text-blue-500 cursor-help dark:text-blue-400"
|
|
/>
|
|
<!-- Invisible bridge to prevent gap -->
|
|
<div class="absolute left-0 top-0 w-full h-8 z-40"></div>
|
|
<!-- Tooltip popup -->
|
|
<div class="invisible group-hover/firmware:visible hover:visible absolute left-0 top-7 z-50 w-80 rounded-lg bg-white dark:bg-gray-800 shadow-xl ring-1 ring-black ring-opacity-5 dark:ring-white dark:ring-opacity-10 p-4">
|
|
<div class="flex items-start gap-3">
|
|
<.icon
|
|
name="hero-arrow-up-circle"
|
|
class="h-5 w-5 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
|
/>
|
|
<div class="flex-1 min-w-0">
|
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
|
Firmware Update Available
|
|
</h4>
|
|
<div class="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
|
|
<div>
|
|
<span class="font-medium">Current:</span>
|
|
<span class="font-mono ml-1">
|
|
{@snmp_device.firmware_version}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span class="font-medium">Latest:</span>
|
|
<span class="font-mono ml-1 text-blue-600 dark:text-blue-400">
|
|
{@available_firmware.version}
|
|
</span>
|
|
<%= if @available_firmware.release_date do %>
|
|
<span class="text-gray-500 dark:text-gray-500">
|
|
(released {format_date(
|
|
@available_firmware.release_date
|
|
)})
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 flex gap-2">
|
|
<a
|
|
href={@available_firmware.download_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium bg-blue-600 text-white hover:bg-blue-700"
|
|
>
|
|
Download
|
|
<.icon
|
|
name="hero-arrow-top-right-on-square"
|
|
class="h-3 w-3"
|
|
/>
|
|
</a>
|
|
<%= if @available_firmware.changelog_url do %>
|
|
<a
|
|
href={@available_firmware.changelog_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20"
|
|
>
|
|
Release Notes
|
|
<.icon
|
|
name="hero-arrow-top-right-on-square"
|
|
class="h-3 w-3"
|
|
/>
|
|
</a>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</dd>
|
|
</div>
|
|
|
|
<div
|
|
:if={@snmp_device.serial_number}
|
|
class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"
|
|
>
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Serial Number</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono">
|
|
{@snmp_device.serial_number}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Object ID</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono">
|
|
{@snmp_device.sys_object_id || "N/A"}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Contact</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{@snmp_device.sys_contact || "N/A"}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Location</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{@snmp_device.sys_location || "N/A"}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Uptime</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{format_uptime(@snmp_device.sys_uptime)}
|
|
</dd>
|
|
</div>
|
|
<% else %>
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Name</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{@device.name}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">IP Address</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono">
|
|
{@device.ip_address}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Device Added</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{format_device_age(@device.inserted_at)}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-1.5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Last Discovered</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{if @device.last_discovery_at do
|
|
format_device_age(@device.last_discovery_at)
|
|
else
|
|
"Never"
|
|
end}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-1.5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">Last Polled</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{if @device.last_snmp_poll_at do
|
|
format_device_age(@device.last_snmp_poll_at)
|
|
else
|
|
"Never"
|
|
end}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<!-- Overall Traffic Chart -->
|
|
<%= if @traffic_chart_data do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<.link
|
|
navigate={~p"/devices/#{@device.id}/graph/traffic"}
|
|
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
Overall Traffic
|
|
</h3>
|
|
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
|
|
</div>
|
|
</.link>
|
|
<div class="p-4">
|
|
<div
|
|
id="traffic-chart"
|
|
phx-hook="SensorChart"
|
|
data-chart={@traffic_chart_data}
|
|
data-unit="bps"
|
|
data-auto-scale="true"
|
|
data-show-zero-line="true"
|
|
style="height: 300px;"
|
|
>
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- ICMP Latency Chart -->
|
|
<%= if @latency_chart_data do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<.link
|
|
navigate={~p"/devices/#{@device.id}/graph/latency"}
|
|
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
ICMP Latency
|
|
</h3>
|
|
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
|
|
</div>
|
|
</.link>
|
|
<div class="p-4">
|
|
<div
|
|
id="latency-chart"
|
|
phx-hook="SensorChart"
|
|
data-chart={@latency_chart_data}
|
|
data-unit="ms"
|
|
data-auto-scale="true"
|
|
style="height: 300px;"
|
|
>
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<!-- Right Column: Graphs and Metrics -->
|
|
<div class="space-y-6">
|
|
<!-- CPU / Processors -->
|
|
<%= if @processor_chart_data || (@processors && length(@processors) > 0) do %>
|
|
<% processor_count = if @processors, do: length(@processors), else: 0
|
|
|
|
avg_load =
|
|
if processor_count > 0 do
|
|
loads = @processors |> Enum.map(& &1.load_percent) |> Enum.reject(&is_nil/1)
|
|
if length(loads) > 0, do: Enum.sum(loads) / length(loads), else: nil
|
|
else
|
|
nil
|
|
end %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<.link
|
|
navigate={~p"/devices/#{@device.id}/graph/processors"}
|
|
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
<%= if processor_count > 0 do %>
|
|
{processor_count} {if processor_count == 1,
|
|
do: "Processor",
|
|
else: "Processors"}
|
|
<% else %>
|
|
Processors
|
|
<% end %>
|
|
</h3>
|
|
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
|
|
</div>
|
|
</.link>
|
|
<!-- Show chart if we have time-series data -->
|
|
<%= if @processor_chart_data do %>
|
|
<div class="p-4">
|
|
<div
|
|
id="processor-chart"
|
|
phx-hook="SensorChart"
|
|
data-chart={@processor_chart_data}
|
|
data-unit="%"
|
|
style="height: 200px;"
|
|
>
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- Show combined processor load -->
|
|
<%= if avg_load do %>
|
|
<div class={[
|
|
"p-4",
|
|
@processor_chart_data && "border-t border-gray-200 dark:border-white/10"
|
|
]}>
|
|
<div class="flex items-center justify-between gap-4">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
|
Average Load
|
|
</span>
|
|
<div class="flex items-center gap-3 flex-1 max-w-md">
|
|
<div class="flex-1 bg-gray-200 rounded-full h-3 dark:bg-gray-700">
|
|
<div
|
|
class={[
|
|
"h-3 rounded-full transition-all",
|
|
avg_load < 50 && "bg-green-500",
|
|
avg_load >= 50 && avg_load < 80 && "bg-yellow-500",
|
|
avg_load >= 80 && "bg-red-500"
|
|
]}
|
|
style={"width: #{min(avg_load, 100)}%"}
|
|
>
|
|
</div>
|
|
</div>
|
|
<span class="text-sm font-medium text-gray-900 dark:text-white w-12 text-right">
|
|
{Float.round(avg_load, 0)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<!-- Memory Usage Chart -->
|
|
<%= if @memory_chart_data do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<.link
|
|
navigate={~p"/devices/#{@device.id}/graph/memory"}
|
|
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
Memory Usage
|
|
</h3>
|
|
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-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 -->
|
|
<%= if @storage_sensors && length(@storage_sensors) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
Storage Usage
|
|
</h3>
|
|
</div>
|
|
<div class="p-4">
|
|
<dl class="space-y-1">
|
|
<%= for sensor <- @storage_sensors do %>
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/storage?sensor_id=#{sensor.id}"
|
|
}
|
|
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
|
>
|
|
{sensor.sensor_descr}
|
|
</.link>
|
|
</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
<%= if sensor.latest_reading do %>
|
|
{format_sensor_value(
|
|
sensor.latest_reading.value,
|
|
sensor.sensor_divisor
|
|
)}
|
|
{sensor.sensor_unit}
|
|
<% else %>
|
|
N/A
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- Physical Storage Volumes (from HOST-RESOURCES-MIB) -->
|
|
<%= if @storage && length(@storage) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
Storage Volumes
|
|
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
({length(@storage)})
|
|
</span>
|
|
</h3>
|
|
</div>
|
|
<!-- Storage Volume Chart -->
|
|
<%= if @storage_volume_chart_data do %>
|
|
<div class="p-4 border-b border-gray-200 dark:border-white/10">
|
|
<div
|
|
id="storage-volume-chart"
|
|
phx-hook="SensorChart"
|
|
data-chart={@storage_volume_chart_data}
|
|
data-unit="%"
|
|
style="height: 200px;"
|
|
>
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<div class="p-4 space-y-4">
|
|
<%= for storage <- @storage do %>
|
|
<% usage_percent =
|
|
if storage.total_bytes && storage.total_bytes > 0 do
|
|
Float.round(storage.used_bytes / storage.total_bytes * 100, 1)
|
|
else
|
|
0.0
|
|
end %>
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/storage_volume?storage_id=#{storage.id}"
|
|
}
|
|
class="block hover:bg-gray-50 dark:hover:bg-gray-700/50 -mx-2 px-2 py-1 rounded transition-colors"
|
|
>
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:underline">
|
|
{storage.description || "Storage #{storage.storage_index}"}
|
|
</span>
|
|
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{format_bytes(storage.used_bytes)} / {format_bytes(storage.total_bytes)}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex-1 bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
|
<div
|
|
class={[
|
|
"h-2 rounded-full transition-all",
|
|
usage_percent < 70 && "bg-green-500",
|
|
usage_percent >= 70 && usage_percent < 90 && "bg-yellow-500",
|
|
usage_percent >= 90 && "bg-red-500"
|
|
]}
|
|
style={"width: #{min(usage_percent, 100)}%"}
|
|
>
|
|
</div>
|
|
</div>
|
|
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-12 text-right">
|
|
{usage_percent}%
|
|
</span>
|
|
</div>
|
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{String.replace(storage.storage_type || "other", "_", " ")
|
|
|> String.capitalize()}
|
|
</div>
|
|
</.link>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- Temperature Sensors -->
|
|
<%= if @temperature_sensors && length(@temperature_sensors) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
Temperature
|
|
</h3>
|
|
</div>
|
|
<div class="p-4">
|
|
<dl class="space-y-1">
|
|
<%= for sensor <- @temperature_sensors do %>
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/temperature?sensor_id=#{sensor.id}"
|
|
}
|
|
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
|
>
|
|
{sensor.sensor_descr}
|
|
</.link>
|
|
</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
<%= if sensor.latest_reading do %>
|
|
{format_sensor_value(
|
|
sensor.latest_reading.value,
|
|
sensor.sensor_divisor
|
|
)}
|
|
{sensor.sensor_unit || "°C"}
|
|
<% else %>
|
|
N/A
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- Voltage Sensors -->
|
|
<%= if @voltage_sensors && length(@voltage_sensors) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Voltage</h3>
|
|
</div>
|
|
<div class="p-4">
|
|
<dl class="space-y-1">
|
|
<%= for sensor <- @voltage_sensors do %>
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/voltage?sensor_id=#{sensor.id}"
|
|
}
|
|
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
|
>
|
|
{sensor.sensor_descr}
|
|
</.link>
|
|
</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
<%= if sensor.latest_reading do %>
|
|
{format_sensor_value(
|
|
sensor.latest_reading.value,
|
|
sensor.sensor_divisor
|
|
)}
|
|
{sensor.sensor_unit}
|
|
<% else %>
|
|
N/A
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- SFP Transceivers -->
|
|
<%= if @grouped_transceivers && length(@grouped_transceivers) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
Transceivers
|
|
</h3>
|
|
</div>
|
|
<div class="p-4">
|
|
<dl class="space-y-1">
|
|
<%= for {_transceiver_name, sensors} <- @grouped_transceivers do %>
|
|
<%= for sensor <- sensors do %>
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/dbm?sensor_id=#{sensor.id}"
|
|
}
|
|
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
|
>
|
|
{sensor.sensor_descr}
|
|
</.link>
|
|
</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono">
|
|
<%= if sensor.latest_reading do %>
|
|
{format_sensor_value(
|
|
sensor.latest_reading.value,
|
|
sensor.sensor_divisor
|
|
)}
|
|
{sensor.sensor_unit}
|
|
<% else %>
|
|
N/A
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- Count Sensors (DHCP Leases, Connections, etc.) -->
|
|
<%= if (@general_counters && length(@general_counters) > 0) || (@firewall_counters && length(@firewall_counters) > 0) do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Counters</h3>
|
|
</div>
|
|
<div class="p-4 space-y-4">
|
|
<!-- General Counters -->
|
|
<%= if @general_counters && length(@general_counters) > 0 do %>
|
|
<dl class="space-y-1">
|
|
<%= for sensor <- @general_counters do %>
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}"
|
|
}
|
|
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
|
>
|
|
{sensor.sensor_descr}
|
|
</.link>
|
|
</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
<%= if sensor.latest_reading && sensor.latest_reading.value do %>
|
|
{trunc(sensor.latest_reading.value)}
|
|
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
|
|
{sensor.sensor_unit}
|
|
<% end %>
|
|
<% else %>
|
|
N/A
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
<% end %>
|
|
<!-- Firewall Counters -->
|
|
<%= if @firewall_counters && length(@firewall_counters) > 0 do %>
|
|
<div>
|
|
<h4 class="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wider">
|
|
Firewall
|
|
</h4>
|
|
<dl class="space-y-1">
|
|
<%= for sensor <- @firewall_counters do %>
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}"
|
|
}
|
|
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
|
>
|
|
{sensor.sensor_descr}
|
|
</.link>
|
|
</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
<%= if sensor.latest_reading && sensor.latest_reading.value do %>
|
|
{trunc(sensor.latest_reading.value)}
|
|
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
|
|
{sensor.sensor_unit}
|
|
<% end %>
|
|
<% else %>
|
|
N/A
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- Wireless Sensors (Frequency, Power, CCQ, etc.) -->
|
|
<%= if @wireless_sensors && length(@wireless_sensors) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Wireless</h3>
|
|
</div>
|
|
<div class="p-4">
|
|
<dl class="space-y-1">
|
|
<%= for sensor <- @wireless_sensors do %>
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="text-sm text-gray-600 dark:text-gray-400">
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/wireless?sensor_id=#{sensor.id}"
|
|
}
|
|
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
|
>
|
|
{sensor.sensor_descr}
|
|
</.link>
|
|
</dt>
|
|
<dd class="text-sm font-medium text-gray-900 dark:text-white">
|
|
<%= if sensor.latest_reading && sensor.latest_reading.value do %>
|
|
<%= if sensor.sensor_type in ["clients", "ccq", "utilization"] do %>
|
|
{trunc(sensor.latest_reading.value)}
|
|
<% else %>
|
|
{Float.round(sensor.latest_reading.value, 1)}
|
|
<% end %>
|
|
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
|
|
{sensor.sensor_unit}
|
|
<% end %>
|
|
<% else %>
|
|
N/A
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% "ports" -> %>
|
|
<%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %>
|
|
<div class="space-y-6">
|
|
<%= for {category, interfaces} <- @interfaces_by_type do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{category} Interfaces
|
|
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
({length(interfaces)})
|
|
</span>
|
|
</h3>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
|
<tr class="text-xs text-gray-600 dark:text-gray-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">IP Address</th>
|
|
<th class="px-4 py-3 text-left font-medium">MAC</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for interface <- interfaces do %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td class="px-4 py-3 text-gray-900 dark:text-white">
|
|
{interface.if_index}
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{interface.id}"
|
|
}
|
|
class="hover:text-blue-600 dark:hover:text-blue-400"
|
|
>
|
|
<div class="font-medium text-gray-900 dark:text-white hover:underline">
|
|
{interface.if_name || interface.if_descr}
|
|
</div>
|
|
<%= if interface.if_alias do %>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
{interface.if_alias}
|
|
</div>
|
|
<% end %>
|
|
</.link>
|
|
</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-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
|
]}>
|
|
{String.upcase(interface.if_oper_status || "unknown")}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-900 dark:text-white">
|
|
{format_speed(interface.if_speed)}
|
|
</td>
|
|
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
<% interface_ips =
|
|
Map.get(@ip_addresses_by_interface, interface.id, []) %>
|
|
<%= if Enum.empty?(interface_ips) do %>
|
|
<span class="text-gray-400">-</span>
|
|
<% else %>
|
|
<div class="space-y-0.5">
|
|
<%= for ip <- interface_ips do %>
|
|
<div>
|
|
{ip.ip_address}
|
|
<%= if ip.prefix_length do %>
|
|
/{ip.prefix_length}
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
{interface.if_phys_address || "-"}
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% else %>
|
|
<div class="text-center py-12">
|
|
<p class="text-gray-500 dark:text-gray-400">No interfaces discovered</p>
|
|
</div>
|
|
<% end %>
|
|
<% "neighbors" -> %>
|
|
<%= if @neighbors && length(@neighbors) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
Discovered Neighbors
|
|
</h3>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
|
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
|
<th class="px-4 py-3 text-left font-medium">Protocol</th>
|
|
<th class="px-4 py-3 text-left font-medium">Local Port</th>
|
|
<th class="px-4 py-3 text-left font-medium">Remote Device</th>
|
|
<th class="px-4 py-3 text-left font-medium">Remote Port</th>
|
|
<th class="px-4 py-3 text-left font-medium">IP Address</th>
|
|
<th class="px-4 py-3 text-left font-medium">Platform</th>
|
|
<th class="px-4 py-3 text-left font-medium">Capabilities</th>
|
|
<th class="px-4 py-3 text-left font-medium">Last Seen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for neighbor <- @neighbors do %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td class="px-4 py-3">
|
|
<span class={[
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium uppercase",
|
|
neighbor.protocol == "lldp" &&
|
|
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
|
neighbor.protocol == "cdp" &&
|
|
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
|
]}>
|
|
{neighbor.protocol}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{neighbor.interface.id}"
|
|
}
|
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
|
>
|
|
{neighbor.interface.if_name || neighbor.interface.if_descr}
|
|
</.link>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<%= if neighbor.matched_device do %>
|
|
<.link
|
|
navigate={~p"/devices/#{neighbor.matched_device.id}"}
|
|
class="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
|
>
|
|
{neighbor.matched_device.name}
|
|
</.link>
|
|
<% else %>
|
|
<div class="text-gray-900 dark:text-white">
|
|
<%= if neighbor.remote_system_name do %>
|
|
{neighbor.remote_system_name |> String.split("/") |> List.first()}
|
|
<% else %>
|
|
Unknown
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div class="text-gray-900 dark:text-white">
|
|
{neighbor.remote_port_id || "-"}
|
|
</div>
|
|
<%= if !neighbor.matched_device && neighbor.remote_chassis_id do %>
|
|
<div class="text-xs font-mono text-gray-500 dark:text-gray-400">
|
|
{neighbor.remote_chassis_id}
|
|
</div>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3 font-mono text-sm text-gray-900 dark:text-white">
|
|
{neighbor.remote_address || "-"}
|
|
</td>
|
|
<td class="px-4 py-3 text-xs text-gray-600 dark:text-gray-400">
|
|
{neighbor.remote_platform || "-"}
|
|
</td>
|
|
<td class="px-4 py-3 text-xs text-gray-600 dark:text-gray-400">
|
|
<%= if neighbor.remote_capabilities && length(neighbor.remote_capabilities) > 0 do %>
|
|
{Enum.join(neighbor.remote_capabilities, ", ")}
|
|
<% else %>
|
|
-
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
|
{time_ago(neighbor.last_discovered_at)}
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
|
|
<div class="text-center">
|
|
<.icon name="hero-server-stack" class="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
|
No neighbors discovered
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
This device doesn't have any LLDP or CDP neighbors, or neighbor discovery hasn't run yet.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% "arp" -> %>
|
|
<%= if @arp_entries && length(@arp_entries) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
ARP Table
|
|
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
({length(@arp_entries)} entries)
|
|
</span>
|
|
</h3>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
|
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
|
<th class="px-4 py-3 text-left font-medium">IP Address</th>
|
|
<th class="px-4 py-3 text-left font-medium">MAC Address</th>
|
|
<th class="px-4 py-3 text-left font-medium">Interface</th>
|
|
<th class="px-4 py-3 text-left font-medium">Type</th>
|
|
<th class="px-4 py-3 text-left font-medium">Last Seen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for arp <- @arp_entries do %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td class="px-4 py-3 font-mono text-gray-900 dark:text-white">
|
|
{arp.ip_address}
|
|
</td>
|
|
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
{arp.mac_address}
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
|
<%= if arp.interface do %>
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{arp.interface.id}"
|
|
}
|
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
|
>
|
|
{arp.interface.if_name || arp.interface.if_descr}
|
|
</.link>
|
|
<% else %>
|
|
<span class="text-gray-400">ifIndex {arp.if_index}</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class={[
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
|
arp.entry_type == "dynamic" &&
|
|
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
|
arp.entry_type == "static" &&
|
|
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
|
arp.entry_type not in ["dynamic", "static"] &&
|
|
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
|
]}>
|
|
{arp.entry_type || "unknown"}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
|
{time_ago(arp.last_seen_at)}
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
|
|
<div class="text-center">
|
|
<.icon name="hero-table-cells" class="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
|
No ARP entries discovered
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
This device doesn't have any ARP entries, or ARP discovery hasn't run yet.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% "mac" -> %>
|
|
<%= if @mac_addresses && length(@mac_addresses) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
MAC Address Table
|
|
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
({length(@mac_addresses)} entries)
|
|
</span>
|
|
</h3>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
|
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
|
<th class="px-4 py-3 text-left font-medium">MAC Address</th>
|
|
<th class="px-4 py-3 text-left font-medium">VLAN</th>
|
|
<th class="px-4 py-3 text-left font-medium">Interface</th>
|
|
<th class="px-4 py-3 text-left font-medium">Status</th>
|
|
<th class="px-4 py-3 text-left font-medium">Last Seen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for mac <- @mac_addresses do %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td class="px-4 py-3 font-mono text-xs text-gray-900 dark:text-white">
|
|
{mac.mac_address}
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
|
<%= if mac.vlan_id do %>
|
|
{mac.vlan_id}
|
|
<% else %>
|
|
<span class="text-gray-400">-</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
|
<%= if mac.interface do %>
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{mac.interface.id}"
|
|
}
|
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
|
>
|
|
{mac.interface.if_name || mac.interface.if_descr}
|
|
</.link>
|
|
<% else %>
|
|
<span class="text-gray-400">Port {mac.port_index}</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class={[
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
|
mac.entry_status == "learned" &&
|
|
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
|
mac.entry_status == "self" &&
|
|
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
|
mac.entry_status == "mgmt" &&
|
|
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
|
mac.entry_status not in ["learned", "self", "mgmt"] &&
|
|
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
|
]}>
|
|
{mac.entry_status || "unknown"}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
|
{time_ago(mac.last_seen_at)}
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
|
|
<div class="text-center">
|
|
<.icon name="hero-table-cells" class="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
|
No MAC addresses discovered
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
This device doesn't have any MAC forwarding entries, or MAC discovery hasn't run yet.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% "vlans" -> %>
|
|
<%= if @vlans && length(@vlans) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
Configured VLANs
|
|
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
({length(@vlans)})
|
|
</span>
|
|
</h3>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
|
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
|
<th class="px-4 py-3 text-left font-medium">VLAN ID</th>
|
|
<th class="px-4 py-3 text-left font-medium">Name</th>
|
|
<th class="px-4 py-3 text-left font-medium">Type</th>
|
|
<th class="px-4 py-3 text-left font-medium">Status</th>
|
|
<th class="px-4 py-3 text-left font-medium">Last Checked</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for vlan <- @vlans do %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td class="px-4 py-3 text-gray-900 dark:text-white font-mono">
|
|
{vlan.vlan_id}
|
|
</td>
|
|
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
|
|
{vlan.vlan_name || "-"}
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
|
{vlan.vlan_type || "ethernet"}
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class={[
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
|
vlan.status == "active" &&
|
|
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
|
vlan.status == "suspended" &&
|
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
|
vlan.status not in ["active", "suspended"] &&
|
|
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
|
]}>
|
|
{String.upcase(vlan.status || "unknown")}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
|
{time_ago(vlan.last_checked_at)}
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
|
|
<div class="text-center">
|
|
<.icon name="hero-square-3-stack-3d" class="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
|
No VLANs discovered
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
This device doesn't have any VLANs configured, or VLAN discovery hasn't run yet.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% "ip_addresses" -> %>
|
|
<div class="space-y-6">
|
|
<!-- IPv4 Addresses -->
|
|
<%= if length(@ipv4_addresses) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 mr-2">
|
|
IPv4
|
|
</span>
|
|
Addresses
|
|
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
({length(@ipv4_addresses)})
|
|
</span>
|
|
</h3>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
|
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
|
<th class="px-4 py-3 text-left font-medium">IP Address</th>
|
|
<th class="px-4 py-3 text-left font-medium">Subnet</th>
|
|
<th class="px-4 py-3 text-left font-medium">Interface</th>
|
|
<th class="px-4 py-3 text-left font-medium">Primary</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for ip <- @ipv4_addresses do %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td class="px-4 py-3 text-gray-900 dark:text-white font-mono">
|
|
{ip.ip_address}
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
|
|
<%= if ip.prefix_length do %>
|
|
/{ip.prefix_length}
|
|
<% else %>
|
|
{ip.subnet_mask || "-"}
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
|
{(ip.snmp_interface &&
|
|
(ip.snmp_interface.if_name || ip.snmp_interface.if_descr)) || "-"}
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<%= if ip.is_primary do %>
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
|
Primary
|
|
</span>
|
|
<% else %>
|
|
<span class="text-gray-400 dark:text-gray-500">-</span>
|
|
<% end %>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- IPv6 Addresses -->
|
|
<%= if length(@ipv6_addresses) > 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 mr-2">
|
|
IPv6
|
|
</span>
|
|
Addresses
|
|
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
({length(@ipv6_addresses)})
|
|
</span>
|
|
</h3>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
|
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
|
<th class="px-4 py-3 text-left font-medium">IP Address</th>
|
|
<th class="px-4 py-3 text-left font-medium">Prefix</th>
|
|
<th class="px-4 py-3 text-left font-medium">Interface</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for ip <- @ipv6_addresses do %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td class="px-4 py-3 text-gray-900 dark:text-white font-mono text-xs">
|
|
{ip.ip_address}
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
|
|
/{ip.prefix_length || "-"}
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
|
{(ip.snmp_interface &&
|
|
(ip.snmp_interface.if_name || ip.snmp_interface.if_descr)) || "-"}
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<!-- No IP addresses found -->
|
|
<%= if length(@ipv4_addresses) == 0 && length(@ipv6_addresses) == 0 do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
|
|
<div class="text-center">
|
|
<.icon name="hero-globe-alt" class="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
|
No IP addresses discovered
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
This device doesn't have any IP addresses discovered, or IP discovery hasn't run yet.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% "backups" -> %>
|
|
<%= if @device.mikrotik_enabled do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
Configuration Backups
|
|
<%= if Enum.any?(@mikrotik_backups) do %>
|
|
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
({length(@mikrotik_backups)})
|
|
</span>
|
|
<% end %>
|
|
</h3>
|
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-blue-700 bg-blue-100 rounded dark:bg-blue-900 dark:text-blue-200">
|
|
<.icon name="hero-beaker" class="h-3 w-3" /> Experimental
|
|
</span>
|
|
</div>
|
|
<%= if @agent_info.agent_token_id do %>
|
|
<button
|
|
phx-click="backup_now"
|
|
phx-throttle="2000"
|
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
|
>
|
|
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Backup Now
|
|
</button>
|
|
<% else %>
|
|
<div class="text-sm text-amber-600 dark:text-amber-400">
|
|
Assign an agent to enable backups
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<%= if Enum.any?(@mikrotik_backups) do %>
|
|
<%!-- Instructions for comparing backups --%>
|
|
<%= if length(@mikrotik_backups) > 1 do %>
|
|
<div class="px-4 py-3 bg-blue-50 dark:bg-blue-950/30 border-b border-blue-100 dark:border-blue-900/50">
|
|
<div class="flex items-start gap-2">
|
|
<.icon
|
|
name="hero-information-circle"
|
|
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
|
/>
|
|
<div class="text-sm text-blue-900 dark:text-blue-200">
|
|
<span class="font-medium">Compare configurations:</span>
|
|
Select any 2 backups using the checkboxes to compare their differences
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
|
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
|
<th
|
|
class="px-4 py-3 text-center font-medium w-12"
|
|
title="Select backups to compare"
|
|
>
|
|
<.icon name="hero-check-circle" class="h-4 w-4 mx-auto" />
|
|
</th>
|
|
<th class="px-4 py-3 text-left font-medium">Date</th>
|
|
<th class="px-4 py-3 text-left font-medium">Original Size</th>
|
|
<th class="px-4 py-3 text-left font-medium">Compressed Size</th>
|
|
<th class="px-4 py-3 text-left font-medium">Ratio</th>
|
|
<th class="px-4 py-3 text-left font-medium">Source</th>
|
|
<th class="px-4 py-3 text-left font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for backup <- @mikrotik_backups do %>
|
|
<% compression_ratio =
|
|
if backup.config_size_bytes && backup.config_size_bytes > 0 do
|
|
Float.round(
|
|
(1 - backup.compressed_size_bytes / backup.config_size_bytes) * 100,
|
|
1
|
|
)
|
|
else
|
|
0.0
|
|
end %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td class="px-4 py-3 text-center">
|
|
<input
|
|
type="checkbox"
|
|
phx-click="toggle_backup_selection"
|
|
phx-value-id={backup.id}
|
|
checked={MapSet.member?(@selected_backup_ids, backup.id)}
|
|
disabled={
|
|
!MapSet.member?(@selected_backup_ids, backup.id) &&
|
|
MapSet.size(@selected_backup_ids) >= 2
|
|
}
|
|
title={
|
|
if MapSet.member?(@selected_backup_ids, backup.id),
|
|
do: "Selected for comparison",
|
|
else:
|
|
if(MapSet.size(@selected_backup_ids) >= 2,
|
|
do: "Maximum 2 backups can be selected",
|
|
else: "Select to compare"
|
|
)
|
|
}
|
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:bg-gray-700"
|
|
/>
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-900 dark:text-white whitespace-nowrap">
|
|
{ToweropsWeb.TimeHelpers.format_iso8601(backup.backed_up_at, @timezone)}
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
|
|
{format_bytes(backup.config_size_bytes)}
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
|
|
{format_bytes(backup.compressed_size_bytes)}
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
|
{compression_ratio}%
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class={[
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
|
backup.trigger_source == "daily_cron" &&
|
|
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
|
backup.trigger_source == "manual" &&
|
|
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
|
backup.trigger_source == "pre_change" &&
|
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
|
]}>
|
|
{String.replace(backup.trigger_source, "_", " ")
|
|
|> String.capitalize()}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
phx-click="download_backup"
|
|
phx-value-id={backup.id}
|
|
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
|
>
|
|
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Download
|
|
</button>
|
|
<%= if ToweropsWeb.Permissions.owner?(@current_scope) do %>
|
|
<button
|
|
phx-click="delete_backup"
|
|
phx-value-id={backup.id}
|
|
data-confirm="Are you sure you want to delete this backup? This action cannot be undone."
|
|
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
|
>
|
|
<.icon name="hero-trash" class="h-4 w-4" /> Delete
|
|
</button>
|
|
<% end %>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<%!-- Pagination --%>
|
|
<%= if assigns[:pagination] do %>
|
|
<div class="px-4 py-3 border-t border-gray-200 dark:border-white/10">
|
|
<.pagination
|
|
meta={@pagination}
|
|
path={~p"/devices/#{@device.id}"}
|
|
params={%{"tab" => "backups"}}
|
|
/>
|
|
</div>
|
|
<% end %>
|
|
<%!-- Floating Compare Button --%>
|
|
<%= if MapSet.size(@selected_backup_ids) > 0 do %>
|
|
<div class="fixed bottom-8 right-8 z-50">
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-white/10 p-4">
|
|
<div class="flex flex-col gap-2">
|
|
<%!-- Selection status --%>
|
|
<div class="flex items-center gap-2 text-sm">
|
|
<.icon
|
|
name="hero-check-circle"
|
|
class="h-5 w-5 text-blue-600 dark:text-blue-400"
|
|
/>
|
|
<div class="text-gray-700 dark:text-gray-300">
|
|
<span class="font-medium">{MapSet.size(@selected_backup_ids)}</span>
|
|
of 2 backups selected
|
|
</div>
|
|
</div>
|
|
<%!-- Instructions when only 1 selected --%>
|
|
<%= if MapSet.size(@selected_backup_ids) == 1 do %>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 pl-7">
|
|
Select one more backup to compare
|
|
</div>
|
|
<% end %>
|
|
<%!-- Action buttons --%>
|
|
<div class="flex items-center gap-2 mt-1">
|
|
<button
|
|
phx-click="clear_selection"
|
|
class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded-lg transition-colors dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
phx-click="compare_selected"
|
|
disabled={MapSet.size(@selected_backup_ids) != 2}
|
|
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-lg transition-colors shadow-sm"
|
|
>
|
|
<.icon name="hero-arrows-right-left" class="h-4 w-4" />
|
|
{if MapSet.size(@selected_backup_ids) == 2,
|
|
do: "Compare Selected",
|
|
else: "Compare"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% else %>
|
|
<div class="p-8 text-center">
|
|
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
|
No backups yet
|
|
</h3>
|
|
<%= if @agent_info.agent_token_id do %>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Automatic backups run daily at {ToweropsWeb.TimeHelpers.format_utc_hour(
|
|
7,
|
|
@timezone
|
|
)}.
|
|
</p>
|
|
<% else %>
|
|
<p class="mt-1 text-sm text-amber-600 dark:text-amber-400">
|
|
Assign an agent to enable automatic backups.
|
|
</p>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% else %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
|
|
<div class="text-center">
|
|
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
|
MikroTik API not enabled
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Enable MikroTik API in device settings to enable configuration backups.
|
|
</p>
|
|
<div class="mt-6">
|
|
<.button navigate={~p"/devices/#{@device.id}/edit"}>
|
|
Edit Device Settings
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% "logs" -> %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Event Logs</h3>
|
|
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
|
Showing the most recent 100 events
|
|
</p>
|
|
</div>
|
|
<%= if Enum.any?(@events) do %>
|
|
<div class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for event <- @events do %>
|
|
<div class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<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-gray-900 dark:text-white">
|
|
{event.message}
|
|
</p>
|
|
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-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-gray-500 dark:text-gray-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-gray-400" />
|
|
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
|
No events yet
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Events will appear here as interface states change, speeds change, or other monitored changes occur.
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% "debug" -> %>
|
|
<div class="bg-white dark:bg-gray-800/50 shadow sm:rounded-lg">
|
|
<div class="px-4 py-5 sm:p-6">
|
|
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
|
|
Raw Discovery Data
|
|
</h3>
|
|
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
|
|
<p>
|
|
Raw SNMP OID values collected during the last discovery run at
|
|
<%= if @snmp_device.last_discovery_at do %>
|
|
{ToweropsWeb.TimeHelpers.format_iso8601(
|
|
@snmp_device.last_discovery_at,
|
|
@timezone
|
|
)}
|
|
<% else %>
|
|
unknown time
|
|
<% end %>.
|
|
</p>
|
|
</div>
|
|
<%= if @snmp_device.raw_discovery_data do %>
|
|
<div class="mt-5">
|
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
|
System OIDs
|
|
</h4>
|
|
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
|
<tr>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Field
|
|
</th>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
OID
|
|
</th>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Value
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white dark:bg-gray-800/50 divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for {key, data} <- Map.get(@snmp_device.raw_discovery_data, "system_oids", %{}) do %>
|
|
<tr>
|
|
<td class="px-3 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
|
{key}
|
|
</td>
|
|
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">
|
|
{data["oid"]}
|
|
</td>
|
|
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
|
|
<code class="text-xs">{inspect(data["value"])}</code>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<%= if Map.get(@snmp_device.raw_discovery_data, "health_sensor_table") do %>
|
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white mt-6 mb-3">
|
|
Health Sensor Table Walk
|
|
</h4>
|
|
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
|
<tr>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
OID
|
|
</th>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Value
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white dark:bg-gray-800/50 divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for {oid, value} <- Map.get(@snmp_device.raw_discovery_data, "health_sensor_table", %{}) do %>
|
|
<tr>
|
|
<td class="px-3 py-4 whitespace-nowrap text-sm font-mono text-gray-500 dark:text-gray-400">
|
|
{oid}
|
|
</td>
|
|
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
|
|
<code class="text-xs">{inspect(value)}</code>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if Map.get(@snmp_device.raw_discovery_data, "discovered_sensors") do %>
|
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white mt-6 mb-3">
|
|
Discovered Sensors
|
|
</h4>
|
|
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
|
<tr>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Type
|
|
</th>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Description
|
|
</th>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
OID
|
|
</th>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Value
|
|
</th>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
State
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white dark:bg-gray-800/50 divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for sensor <- Map.get(@snmp_device.raw_discovery_data, "discovered_sensors", []) do %>
|
|
<tr>
|
|
<td class="px-3 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
|
{sensor["type"]}
|
|
</td>
|
|
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
|
{sensor["descr"]}
|
|
</td>
|
|
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">
|
|
{sensor["oid"]}
|
|
</td>
|
|
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
|
|
<code class="text-xs">{sensor["value"]}</code>
|
|
<%= if sensor["unit"] && sensor["unit"] != "" do %>
|
|
<span class="text-gray-500 dark:text-gray-400 ml-1">
|
|
{sensor["unit"]}
|
|
</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
|
|
<%= if sensor["state_descr"] do %>
|
|
<span class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
|
|
{sensor["state_descr"]}
|
|
</span>
|
|
<% else %>
|
|
<span class="text-gray-400">-</span>
|
|
<% end %>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
|
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white mt-6 mb-3">
|
|
Copyable Raw Data
|
|
</h4>
|
|
<div class="rounded-lg border border-gray-200 dark:border-white/10">
|
|
<textarea
|
|
readonly
|
|
rows="10"
|
|
class="w-full p-3 font-mono text-xs bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white border-0 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
onclick="this.select()"
|
|
><%= Jason.encode!(%{device_id: @device.id, raw_discovery_data: @snmp_device.raw_discovery_data}, pretty: true) %></textarea>
|
|
</div>
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
Click the text box to select all data, then copy with Cmd+C or Ctrl+C
|
|
</p>
|
|
</div>
|
|
<% else %>
|
|
<div class="mt-5 p-4 bg-gray-50 dark:bg-gray-900 rounded-md">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
No raw discovery data available. Run discovery again to collect debug data.
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% _ -> %>
|
|
<div class="text-center py-12">
|
|
<p class="text-gray-500 dark:text-gray-400">Tab not found</p>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</Layouts.authenticated>
|