- Fix broken route paths in dashboard (/discovery, /subscribers/trace, /config-changes) - Fix insights empty state settings link (org-scoped route) - Add underscores to all 86400 literals across 6 files - Alphabetize aliases in search.ex and agent_channel.ex - Extract changelog parser helper to reduce nesting - Extract dashboard impact calculation to reduce nesting - Refactor agent_channel config change detection (pattern match, extract function) - Combine double Enum.reject into single call in trace.ex - Fix line length in trace.ex search query - Replace length/1 > 0 with != [] in trace_live - Run mix format on all files
2924 lines
145 KiB
Text
2924 lines
145 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="devices"
|
|
>
|
|
<div class="mb-6 -mt-2">
|
|
<.breadcrumb items={
|
|
[
|
|
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
|
%{label: "Devices", navigate: ~p"/devices"}
|
|
] ++
|
|
if(@device.site,
|
|
do: [%{label: @device.site.name, navigate: ~p"/sites/#{@device.site.id}"}],
|
|
else: []
|
|
) ++
|
|
[%{label: @device.name}]
|
|
} />
|
|
|
|
<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 %>
|
|
<% 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 %>
|
|
|
|
<%= if @device.mikrotik_enabled do %>
|
|
<.link
|
|
navigate={~p"/devices/#{@device.id}/config-timeline"}
|
|
class="whitespace-nowrap py-4 px-1 border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-clock" class="h-3.5 w-3.5 text-orange-500 dark:text-orange-400" />
|
|
Config Timeline
|
|
</span>
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%= if @preseem_access_point do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=preseem"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if(@active_tab == "preseem",
|
|
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"
|
|
)
|
|
]}
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-signal" class="h-3.5 w-3.5 text-purple-500 dark:text-purple-400" />
|
|
Preseem
|
|
</span>
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%= if @gaiia_item do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=gaiia"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if(@active_tab == "gaiia",
|
|
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"
|
|
)
|
|
]}
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon
|
|
name="hero-building-office"
|
|
class="h-3.5 w-3.5 text-emerald-500 dark:text-emerald-400"
|
|
/> Gaiia
|
|
</span>
|
|
</.link>
|
|
<% end %>
|
|
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=checks"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if @active_tab == "checks" 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
|
|
]}
|
|
>
|
|
Checks
|
|
</.link>
|
|
|
|
<.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 %>
|
|
|
|
<%!-- Recent Config Changes mini-card --%>
|
|
<%= if @device.mikrotik_enabled && assigns[:recent_config_changes] && Enum.any?(@recent_config_changes) 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 flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
|
|
<.icon name="hero-clock" class="h-4 w-4 text-orange-500" />
|
|
Recent Config Changes
|
|
</h3>
|
|
<.link
|
|
navigate={~p"/devices/#{@device.id}/config-timeline"}
|
|
class="text-xs text-blue-500 hover:underline"
|
|
>
|
|
View Timeline →
|
|
</.link>
|
|
</div>
|
|
<div class="p-3 space-y-2">
|
|
<%= for event <- Enum.take(@recent_config_changes, 5) do %>
|
|
<div class="flex items-center justify-between text-sm">
|
|
<div class="flex items-center gap-2">
|
|
<span class={"w-2 h-2 rounded-full #{config_change_dot_color(event)}"} />
|
|
<span class="text-gray-600 dark:text-gray-400">
|
|
{Calendar.strftime(event.changed_at, "%b %d %H:%M")}
|
|
</span>
|
|
<span
|
|
:for={s <- Enum.take(event.sections_changed, 3)}
|
|
class="badge badge-xs badge-ghost"
|
|
>
|
|
{s}
|
|
</span>
|
|
</div>
|
|
<span class="text-gray-500 text-xs">{event.change_size} lines</span>
|
|
</div>
|
|
<% end %>
|
|
</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>
|
|
<%!-- Connected Devices --%>
|
|
<%= if @connected_devices != [] do %>
|
|
<div class="mt-6 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">
|
|
Connected Devices
|
|
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
({length(@connected_devices)} links)
|
|
</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">Device</th>
|
|
<th class="px-4 py-3 text-left font-medium">IP Address</th>
|
|
<th class="px-4 py-3 text-left font-medium">Interface</th>
|
|
<th class="px-4 py-3 text-left font-medium">Link Type</th>
|
|
<th class="px-4 py-3 text-left font-medium">Confidence</th>
|
|
<th class="px-4 py-3 text-left font-medium">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for link <- @connected_devices do %>
|
|
<% peer_device =
|
|
cond do
|
|
link.target_device && link.target_device.id != @device.id ->
|
|
link.target_device
|
|
|
|
link.source_device && link.source_device.id != @device.id ->
|
|
link.source_device
|
|
|
|
link.target_device ->
|
|
link.target_device
|
|
|
|
true ->
|
|
nil
|
|
end %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td class="px-4 py-3">
|
|
<%= if peer_device do %>
|
|
<.link
|
|
navigate={~p"/devices/#{peer_device.id}"}
|
|
class="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
|
>
|
|
{peer_device.name}
|
|
</.link>
|
|
<% else %>
|
|
<div class="text-gray-900 dark:text-white">
|
|
{link.discovered_remote_name || link.discovered_remote_mac ||
|
|
"Unknown"}
|
|
</div>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3 font-mono text-sm text-gray-900 dark:text-white">
|
|
<%= if peer_device do %>
|
|
{peer_device.ip_address}
|
|
<% else %>
|
|
{link.discovered_remote_ip || "-"}
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-900 dark:text-white">
|
|
<%= if link.source_interface do %>
|
|
{link.source_interface.if_name}
|
|
<% else %>
|
|
-
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class={[
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium uppercase",
|
|
cond do
|
|
link.link_type in ["lldp", "cdp"] ->
|
|
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
|
|
|
link.link_type == "mac_match" ->
|
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
|
|
|
link.link_type == "arp_inference" ->
|
|
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
|
|
|
true ->
|
|
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
|
|
end
|
|
]}>
|
|
{link.link_type}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
|
<div
|
|
class={[
|
|
"h-1.5 rounded-full",
|
|
cond do
|
|
link.confidence >= 0.9 -> "bg-green-500"
|
|
link.confidence >= 0.7 -> "bg-blue-500"
|
|
link.confidence >= 0.5 -> "bg-yellow-500"
|
|
true -> "bg-red-500"
|
|
end
|
|
]}
|
|
style={"width: #{trunc(link.confidence * 100)}%"}
|
|
>
|
|
</div>
|
|
</div>
|
|
<span class="text-xs text-gray-600 dark:text-gray-400">
|
|
{trunc(link.confidence * 100)}%
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<%= if peer_device 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">
|
|
Managed
|
|
</span>
|
|
<% else %>
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
|
Discovered
|
|
</span>
|
|
<% end %>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% "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>
|
|
<% "checks" -> %>
|
|
<%= if Enum.empty?(@checks) do %>
|
|
<!-- Empty State -->
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
|
<div class="px-4 py-12 text-center">
|
|
<.icon name="hero-clipboard-document-check" 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 checks configured
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Run discovery to automatically detect sensors, interfaces, and other monitorable items, or add a service check manually.
|
|
</p>
|
|
<div class="mt-6 flex justify-center gap-3">
|
|
<%= if @device.snmp_enabled do %>
|
|
<.button type="button" phx-click="run_discovery">
|
|
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Run Discovery
|
|
</.button>
|
|
<% end %>
|
|
<.button type="button" phx-click="add_check">
|
|
<.icon name="hero-plus" class="h-4 w-4" /> Add Check
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<!-- Checks List -->
|
|
<div class="space-y-6">
|
|
<!-- Header with count and Add button -->
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Checks ({length(@checks)})
|
|
</h2>
|
|
<.button type="button" phx-click="add_check">
|
|
<.icon name="hero-plus" class="h-4 w-4" /> Add Check
|
|
</.button>
|
|
</div>
|
|
<%= for {group_key, group_checks} <- @grouped_checks do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
|
<!-- Group Header -->
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-gray-900/50">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{group_title(group_key)} ({length(group_checks)})
|
|
</h3>
|
|
</div>
|
|
<!-- Checks Table -->
|
|
<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-900/30">
|
|
<tr>
|
|
<th
|
|
scope="col"
|
|
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
|
|
>
|
|
Name
|
|
</th>
|
|
<th
|
|
scope="col"
|
|
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
|
|
>
|
|
Status
|
|
</th>
|
|
<th
|
|
scope="col"
|
|
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
|
|
>
|
|
Value
|
|
</th>
|
|
<th
|
|
scope="col"
|
|
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
|
|
>
|
|
Last Checked
|
|
</th>
|
|
<th scope="col" class="relative px-4 py-3">
|
|
<span class="sr-only">Actions</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for check <- group_checks do %>
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/30">
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
|
{check.name}
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap">
|
|
{render_status_badge(check.current_state)}
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
|
<%= if check.last_check_at do %>
|
|
{get_latest_value(check)}
|
|
<% else %>
|
|
<span class="text-gray-400 dark:text-gray-500">Pending</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
|
<%= if check.last_check_at do %>
|
|
{format_relative_time(check.last_check_at)}
|
|
<% else %>
|
|
<span class="text-gray-400 dark:text-gray-500">Never</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/check?check_id=#{check.id}&range=24h"
|
|
}
|
|
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium"
|
|
>
|
|
Graph
|
|
</.link>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% "gaiia" -> %>
|
|
<%= if @gaiia_item do %>
|
|
<div class="space-y-6">
|
|
<%!-- Inventory Item Details --%>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
|
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
|
Gaiia Inventory Item
|
|
</h3>
|
|
</div>
|
|
<div class="px-4 py-4">
|
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
{@gaiia_item.name || "---"}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Gaiia ID</dt>
|
|
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
|
|
{@gaiia_item.gaiia_id}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Status</dt>
|
|
<dd class="mt-1">
|
|
<span class={[
|
|
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
|
case @gaiia_item.status do
|
|
"active" ->
|
|
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
|
|
|
"decommissioned" ->
|
|
"bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"
|
|
|
|
_ ->
|
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
|
end
|
|
]}>
|
|
{@gaiia_item.status || "unknown"}
|
|
</span>
|
|
</dd>
|
|
</div>
|
|
<%= if @gaiia_item.model_name do %>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Model</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
{@gaiia_item.model_name}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
<%= if @gaiia_item.manufacturer_name do %>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
Manufacturer
|
|
</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
{@gaiia_item.manufacturer_name}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
<%= if @gaiia_item.category do %>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
Category
|
|
</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
{@gaiia_item.category}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
<%= if @gaiia_item.serial_number do %>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
Serial Number
|
|
</dt>
|
|
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
|
|
{@gaiia_item.serial_number}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
<%= if @gaiia_item.mac_address do %>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
MAC Address
|
|
</dt>
|
|
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
|
|
{@gaiia_item.mac_address}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
<%= if @gaiia_item.ip_address do %>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
IP Address (Gaiia)
|
|
</dt>
|
|
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
|
|
{@gaiia_item.ip_address}
|
|
<%= if @device.ip_address && @gaiia_item.ip_address != @device.ip_address do %>
|
|
<span class="ml-2 inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
|
<.icon name="hero-exclamation-triangle-mini" class="mr-0.5 h-3 w-3" />
|
|
Mismatch
|
|
</span>
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Network Site --%>
|
|
<%= if @gaiia_network_site do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
|
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
|
Gaiia Network Site
|
|
</h3>
|
|
</div>
|
|
<div class="px-4 py-4">
|
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
Site Name
|
|
</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
{@gaiia_network_site.name}
|
|
</dd>
|
|
</div>
|
|
<%= if @gaiia_network_site.account_count do %>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
Subscribers
|
|
</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
{@gaiia_network_site.account_count}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
<%= if @gaiia_network_site.total_mrr do %>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
Monthly Revenue
|
|
</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
${Decimal.round(@gaiia_network_site.total_mrr, 2)}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- Linked Subscriber --%>
|
|
<%= if @gaiia_account do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
|
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
|
Linked Subscriber
|
|
</h3>
|
|
</div>
|
|
<div class="px-4 py-4">
|
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
{@gaiia_account.name}
|
|
</dd>
|
|
</div>
|
|
<%= if @gaiia_account.readable_id do %>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
Account ID
|
|
</dt>
|
|
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
|
|
{@gaiia_account.readable_id}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Status</dt>
|
|
<dd class="mt-1">
|
|
<span class={[
|
|
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
|
case @gaiia_account.status do
|
|
"active" ->
|
|
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
|
|
|
"suspended" ->
|
|
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
|
|
|
_ ->
|
|
"bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"
|
|
end
|
|
]}>
|
|
{@gaiia_account.status || "unknown"}
|
|
</span>
|
|
</dd>
|
|
</div>
|
|
<%= if @gaiia_account.mrr do %>
|
|
<div>
|
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">MRR</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
${Decimal.round(@gaiia_account.mrr, 2)}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- Billing Subscriptions --%>
|
|
<%= if @gaiia_subscriptions != [] do %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
|
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
|
Billing Subscriptions
|
|
</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-white/5">
|
|
<tr>
|
|
<th
|
|
scope="col"
|
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
|
>
|
|
Plan
|
|
</th>
|
|
<th
|
|
scope="col"
|
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
|
>
|
|
Status
|
|
</th>
|
|
<th
|
|
scope="col"
|
|
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
|
>
|
|
MRR
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
|
|
<%= for sub <- @gaiia_subscriptions do %>
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-white/5">
|
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
|
|
{sub.product_name || "---"}
|
|
</td>
|
|
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
|
<span class={[
|
|
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
|
case sub.status do
|
|
"active" ->
|
|
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
|
|
|
"suspended" ->
|
|
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
|
|
|
_ ->
|
|
"bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"
|
|
end
|
|
]}>
|
|
{sub.status || "unknown"}
|
|
</span>
|
|
</td>
|
|
<td class="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-900 dark:text-white">
|
|
<%= if sub.mrr_amount do %>
|
|
${Decimal.round(sub.mrr_amount, 2)}
|
|
<span class="text-gray-400">
|
|
{sub.currency || ""}
|
|
</span>
|
|
<% else %>
|
|
---
|
|
<% end %>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% else %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-8 text-center">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
No Gaiia inventory item linked to this device.
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
<% "preseem" -> %>
|
|
<%= if @preseem_access_point do %>
|
|
<div class="space-y-6">
|
|
<%!-- Insights section --%>
|
|
<%= if @preseem_insights != [] do %>
|
|
<div class="space-y-3">
|
|
<%= for insight <- @preseem_insights do %>
|
|
<div class={[
|
|
"rounded-lg border p-4 flex items-start justify-between",
|
|
case insight.urgency do
|
|
"critical" ->
|
|
"bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
|
|
|
|
"warning" ->
|
|
"bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800"
|
|
|
|
_ ->
|
|
"bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800"
|
|
end
|
|
]}>
|
|
<div class="flex items-start gap-3">
|
|
<div class={[
|
|
"mt-0.5",
|
|
case insight.urgency do
|
|
"critical" -> "text-red-500"
|
|
"warning" -> "text-yellow-500"
|
|
_ -> "text-blue-500"
|
|
end
|
|
]}>
|
|
<%= case insight.urgency do %>
|
|
<% "critical" -> %>
|
|
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
|
|
<% "warning" -> %>
|
|
<.icon name="hero-exclamation-circle" class="h-5 w-5" />
|
|
<% _ -> %>
|
|
<.icon name="hero-information-circle" class="h-5 w-5" />
|
|
<% end %>
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{insight.title}
|
|
</p>
|
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
{insight.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
phx-click="dismiss_insight"
|
|
phx-value-id={insight.id}
|
|
class="ml-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex-shrink-0"
|
|
title="Dismiss"
|
|
>
|
|
<.icon name="hero-x-mark" class="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- Score cards --%>
|
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
|
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">QoE Score</div>
|
|
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
|
{if @preseem_access_point.qoe_score,
|
|
do: Float.round(@preseem_access_point.qoe_score, 1),
|
|
else: "---"}
|
|
</div>
|
|
</div>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
|
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
Capacity Score
|
|
</div>
|
|
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
|
{if @preseem_access_point.capacity_score,
|
|
do: Float.round(@preseem_access_point.capacity_score, 1),
|
|
else: "---"}
|
|
</div>
|
|
</div>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
|
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">RF Score</div>
|
|
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
|
{if @preseem_access_point.rf_score,
|
|
do: Float.round(@preseem_access_point.rf_score, 1),
|
|
else: "---"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Details card --%>
|
|
<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">
|
|
Access Point Details
|
|
</h3>
|
|
</div>
|
|
<div class="px-4 py-3">
|
|
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3">
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
|
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
|
{@preseem_access_point.name || "---"}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
Preseem ID
|
|
</dt>
|
|
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
|
{@preseem_access_point.preseem_id}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
Subscribers
|
|
</dt>
|
|
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
|
{@preseem_access_point.subscriber_count || "---"}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
Busy Hours
|
|
</dt>
|
|
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
|
<%= if @preseem_access_point.busy_hours do %>
|
|
{@preseem_access_point.busy_hours} hrs/day
|
|
<% else %>
|
|
---
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
Airtime Utilization
|
|
</dt>
|
|
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
|
<%= if @preseem_access_point.airtime_utilization do %>
|
|
{Float.round(@preseem_access_point.airtime_utilization, 1)}%
|
|
<% else %>
|
|
---
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
Match Type
|
|
</dt>
|
|
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
|
{@preseem_access_point.match_confidence}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Recent metrics table --%>
|
|
<%= if @preseem_metrics != [] 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">
|
|
Recent QoE Metrics
|
|
</h3>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead>
|
|
<tr>
|
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
Time
|
|
</th>
|
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
Latency (ms)
|
|
</th>
|
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
P95 Latency
|
|
</th>
|
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
Jitter (ms)
|
|
</th>
|
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
Loss (%)
|
|
</th>
|
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
Throughput
|
|
</th>
|
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
Subscribers
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for metric <- @preseem_metrics do %>
|
|
<tr>
|
|
<td class="px-4 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">
|
|
{ToweropsWeb.TimeHelpers.format_iso8601(
|
|
metric.recorded_at,
|
|
@current_scope.timezone,
|
|
@current_scope.time_format
|
|
)}
|
|
</td>
|
|
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
|
{if metric.avg_latency,
|
|
do: Float.round(metric.avg_latency, 1),
|
|
else: "---"}
|
|
</td>
|
|
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
|
{if metric.p95_latency,
|
|
do: Float.round(metric.p95_latency, 1),
|
|
else: "---"}
|
|
</td>
|
|
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
|
{if metric.avg_jitter,
|
|
do: Float.round(metric.avg_jitter, 1),
|
|
else: "---"}
|
|
</td>
|
|
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
|
{if metric.avg_loss, do: Float.round(metric.avg_loss, 2), else: "---"}
|
|
</td>
|
|
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
|
{if metric.avg_throughput,
|
|
do: "#{Float.round(metric.avg_throughput, 1)} Mbps",
|
|
else: "---"}
|
|
</td>
|
|
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
|
{metric.subscriber_count || "---"}
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% else %>
|
|
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-8 text-center">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
No Preseem data linked to this device.
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
<% _ -> %>
|
|
<div class="text-center py-12">
|
|
<p class="text-gray-500 dark:text-gray-400">Tab not found</p>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%= if @show_check_form do %>
|
|
<.live_component
|
|
module={FormComponent}
|
|
id={:new}
|
|
title="Add Service Check"
|
|
action={:new}
|
|
device={@device}
|
|
/>
|
|
<% end %>
|
|
</Layouts.authenticated>
|