towerops/lib/towerops_web/live/device_live/show.html.heex
Graham McIntire 4e3f732f21
ui: comprehensive button styling audit - convert all text-styled actions to proper buttons
Fixed 10 text-styled action buttons across 7 files:

Agent Management:
- Delete Agent buttons (2x) → danger variant
- View Setup links (already fixed) → secondary variant

Organization Settings:
- Remove Member button → danger variant

Admin Pages:
- Edit Overrides button → secondary variant
- Delete Organization button → danger variant
- Remove IP/CIDR button → danger variant
- Delete User button → danger variant

Device Management:
- Delete Backup button → danger variant

All action buttons now use proper .button component with appropriate
variants (danger for destructive actions, secondary for neutral actions).
This provides consistent visual hierarchy and makes destructive actions
more clearly identifiable.
2026-03-09 15:32:42 -05:00

3128 lines
157 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="sticky top-0 z-30 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 py-3 bg-gray-50/95 dark:bg-gray-950/95 backdrop-blur-sm border-b border-gray-200/50 dark:border-white/5 mb-4">
<div class="flex items-start justify-between">
<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 group/ip cursor-pointer"
phx-click={JS.dispatch("phx:copy", detail: %{text: @device.ip_address})}
title={t("Click to copy")}
>
{@device.ip_address}
<.icon
name="hero-clipboard-document"
class="h-3.5 w-3.5 inline ml-1 opacity-0 group-hover/ip:opacity-100 transition-opacity text-gray-400"
/>
</span>
<span class="text-gray-300 dark:text-gray-600">•</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{t("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 %>
{t("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>
<!-- Subscriber Impact Card -->
<%= if @subscriber_impact && @subscriber_impact.subscriber_count > 0 do %>
<div class="mb-4 rounded-lg border border-indigo-200 bg-indigo-50 dark:border-indigo-800/50 dark:bg-indigo-950/30 px-4 py-3">
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<.icon name="hero-users" class="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
<span class="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
{@subscriber_impact.subscriber_count} {if @subscriber_impact.subscriber_count == 1,
do: "subscriber",
else: "subscribers"}
</span>
</div>
<%= if @can_view_financials && @subscriber_impact.mrr do %>
<div class="flex items-center gap-2">
<.icon
name="hero-currency-dollar"
class="h-5 w-5 text-indigo-600 dark:text-indigo-400"
/>
<span class="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
{format_mrr(@subscriber_impact.mrr)}/mo
</span>
</div>
<% end %>
</div>
</div>
<% end %>
<!-- Tabs -->
<div class="border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex space-x-8 overflow-x-auto">
<.link
patch={~p"/devices/#{@device.id}?tab=overview"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
if @active_tab == "overview" do
"border-blue-500 text-blue-600 dark:text-blue-400 font-semibold"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
end
]}
>
{t("Overview")}
</.link>
<%= if @snmp_device do %>
<.link
patch={~p"/devices/#{@device.id}?tab=ports"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
if @active_tab == "ports" do
"border-blue-500 text-blue-600 dark:text-blue-400 font-semibold"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
end
]}
>
{t("Ports")}
</.link>
<% end %>
<.link
patch={~p"/devices/#{@device.id}?tab=neighbors"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
if @active_tab == "neighbors" do
"border-blue-500 text-blue-600 dark:text-blue-400 font-semibold"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
end
]}
>
{t("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 text-sm transition-colors",
if @active_tab == "arp" do
"border-blue-500 text-blue-600 dark:text-blue-400 font-semibold"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
end
]}
>
{t("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 text-sm transition-colors",
if @active_tab == "mac" do
"border-blue-500 text-blue-600 dark:text-blue-400 font-semibold"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
end
]}
>
{t("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 text-sm transition-colors",
if @active_tab == "vlans" do
"border-blue-500 text-blue-600 dark:text-blue-400 font-semibold"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
end
]}
>
{t("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 text-sm transition-colors",
if @active_tab == "ip_addresses" do
"border-blue-500 text-blue-600 dark:text-blue-400 font-semibold"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
end
]}
>
{t("IP Addresses")}
</.link>
<% end %>
<%= if false and @device.mikrotik_enabled do %>
<.link
patch={~p"/devices/#{@device.id}?tab=backups"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
if @active_tab == "backups" do
"border-blue-500 text-blue-600 dark:text-blue-400 font-semibold"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
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" />
{t("Backups")}
</span>
</.link>
<% end %>
<%= if false and @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" />
{t("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 text-sm transition-colors",
if(@active_tab == "preseem",
do: "border-blue-500 text-blue-600 dark:text-blue-400 font-semibold",
else:
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
)
]}
>
<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" />
{t("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 text-sm transition-colors",
if(@active_tab == "gaiia",
do: "border-blue-500 text-blue-600 dark:text-blue-400 font-semibold",
else:
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
)
]}
>
<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 text-sm transition-colors",
if @active_tab == "checks" do
"border-blue-500 text-blue-600 dark:text-blue-400 font-semibold"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
end
]}
>
{t("Checks")}
</.link>
<.link
patch={~p"/devices/#{@device.id}?tab=logs"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
if @active_tab == "logs" do
"border-blue-500 text-blue-600 dark:text-blue-400 font-semibold"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
end
]}
>
{t("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 text-sm transition-colors",
if @active_tab == "debug" do
"border-blue-500 text-blue-600 dark:text-blue-400 font-semibold"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
end
]}
>
{t("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">
{t("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">{t("Resolved IP")}</dt>
<dd
class="text-sm font-medium text-gray-900 dark:text-white font-mono group/rip cursor-pointer"
phx-click={JS.dispatch("phx:copy", detail: %{text: @device.ip_address})}
title={t("Click to copy")}
>
{@device.ip_address}
<.icon
name="hero-clipboard-document"
class="h-3.5 w-3.5 inline ml-1 opacity-0 group-hover/rip:opacity-100 transition-opacity text-gray-400"
/>
</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">
{t("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-400 dark:text-gray-500 dark:text-gray-400">
(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"
>
{t("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"
>
{t("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">
{t("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"
class="relative"
style="height: 300px;"
>
<div class="absolute inset-0 flex items-center justify-center" data-loading>
<div class="flex flex-col items-center gap-2">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 dark:border-gray-700 border-t-blue-500">
</div>
<span class="text-xs text-gray-400">{t("Loading chart...")}</span>
</div>
</div>
<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">
{t("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"
class="relative"
style="height: 300px;"
>
<div class="absolute inset-0 flex items-center justify-center" data-loading>
<div class="flex flex-col items-center gap-2">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 dark:border-gray-700 border-t-blue-500">
</div>
<span class="text-xs text-gray-400">{t("Loading chart...")}</span>
</div>
</div>
<canvas></canvas>
</div>
</div>
</div>
<% end %>
<%!-- Recent Config Changes mini-card --%>
<%= if false && @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" />
{t("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 dark:text-gray-400 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 %>
{t("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">
{t("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">
{t("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">
{t("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 %>
{t("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">
{t("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">
{t("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 %>
{t("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">
{t("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 %>
{t("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">
{t("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 %>
{t("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">
{t("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 %>
{t("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">
{t("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 %>
{t("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">
{t("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 %>
{t("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">
{t("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">
{t("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">
{t("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-3 py-2 text-left font-medium">#</th>
<th class="px-3 py-2 text-left font-medium">Name</th>
<th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-left font-medium">Speed</th>
<th class="px-3 py-2 text-left font-medium">IP Address</th>
<th class="px-3 py-2 text-left font-medium">MAC</th>
<th class="px-3 py-2 text-left font-medium">Capacity</th>
<th class="px-3 py-2 text-left font-medium">Utilization</th>
<th class="px-3 py-2 text-right font-medium">In</th>
<th class="px-3 py-2 text-right font-medium">Out</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 transition-colors">
<td class="px-3 py-2 text-gray-500 dark:text-gray-400 font-mono text-xs">
{interface.if_index}
</td>
<td class="px-3 py-2">
<.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 text-sm">
{interface.if_name || interface.if_descr}
</div>
<%= if interface.if_alias do %>
<div class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
{interface.if_alias}
</div>
<% end %>
</.link>
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center gap-1.5 text-xs">
<span class={[
"h-2 w-2 rounded-full flex-shrink-0",
interface.if_oper_status == "up" && "bg-green-500",
interface.if_oper_status == "down" && "bg-red-500",
interface.if_oper_status not in ["up", "down"] && "bg-gray-400"
]} />
<span class={[
"font-medium",
interface.if_oper_status == "up" &&
"text-green-700 dark:text-green-400",
interface.if_oper_status == "down" &&
"text-red-700 dark:text-red-400",
interface.if_oper_status not in ["up", "down"] &&
"text-gray-600 dark:text-gray-400"
]}>
{String.upcase(interface.if_oper_status || "unknown")}
</span>
</span>
</td>
<td class="px-3 py-2 text-gray-900 dark:text-white text-xs font-mono">
{format_speed(interface.if_speed)}
</td>
<td class="px-3 py-2 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
class="cursor-pointer group/iip"
phx-click={
JS.dispatch("phx:copy", detail: %{text: ip.ip_address})
}
title={t("Click to copy")}
>
{ip.ip_address}
<%= if ip.prefix_length do %>
/{ip.prefix_length}
<% end %>
<.icon
name="hero-clipboard-document"
class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/iip:opacity-100 transition-opacity text-gray-400"
/>
</div>
<% end %>
</div>
<% end %>
</td>
<td class="px-3 py-2 font-mono text-xs text-gray-600 dark:text-gray-400">
<span
:if={interface.if_phys_address}
class="cursor-pointer group/mac"
phx-click={
JS.dispatch("phx:copy",
detail: %{text: interface.if_phys_address}
)
}
title={t("Click to copy")}
>
{interface.if_phys_address}
<.icon
name="hero-clipboard-document"
class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/mac:opacity-100 transition-opacity text-gray-400"
/>
</span>
<span :if={!interface.if_phys_address} class="text-gray-400">-</span>
</td>
<td class="px-3 py-2 text-xs">
<%= if interface.configured_capacity_bps do %>
<div class="flex items-center gap-1">
<span class="font-mono text-gray-900 dark:text-white">
{format_speed(interface.configured_capacity_bps)}
</span>
<span class={[
"px-1 py-0.5 rounded text-[10px] font-medium",
capacity_source_class(interface.capacity_source)
]}>
{capacity_source_label(interface.capacity_source)}
</span>
<button
phx-click="clear_capacity"
phx-value-interface_id={interface.id}
class="text-gray-400 hover:text-red-500 transition-colors ml-0.5"
title={t("Clear capacity")}
>
<.icon name="hero-x-mark" class="h-3 w-3" />
</button>
</div>
<% else %>
<button
phx-click={
JS.push("set_capacity",
value: %{
interface_id: interface.id,
capacity_mbps:
if(interface.if_speed,
do: to_string(div(interface.if_speed, 1_000_000)),
else: ""
)
}
)
}
class="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
{t("Set")}
</button>
<% end %>
</td>
<td class="px-3 py-2 text-xs">
<%= if interface.utilization do %>
<div class="flex items-center gap-2 min-w-[100px]">
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
class={[
"h-full rounded-full transition-all",
utilization_color(interface.utilization.utilization_pct)
]}
style={"width: #{min(interface.utilization.utilization_pct, 100)}%"}
>
</div>
</div>
<span class={[
"font-mono whitespace-nowrap",
utilization_text_color(interface.utilization.utilization_pct)
]}>
{Float.round(interface.utilization.utilization_pct, 1)}%
</span>
</div>
<% else %>
<span class="text-gray-400">-</span>
<% end %>
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
{if interface.latest_stat && interface.latest_stat.if_in_octets,
do: format_bytes(interface.latest_stat.if_in_octets),
else: "-"}
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
{if interface.latest_stat && interface.latest_stat.if_out_octets,
do: format_bytes(interface.latest_stat.if_out_octets),
else: "-"}
</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">
{t("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 %>
{t("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">
{t("No neighbors discovered")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t(
"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">
{t("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">
{t("No ARP entries discovered")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t("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">
{t("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">
{t("No MAC addresses discovered")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t(
"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">
{t("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">
{t("No VLANs discovered")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t(
"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">
{t("IPv4")}
</span>
{t("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">
{t("Primary")}
</span>
<% else %>
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
-
</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">
{t("IPv6")}
</span>
{t("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">
{t("No IP addresses discovered")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t(
"This device doesn't have any IP addresses discovered, or IP discovery hasn't run yet."
)}
</p>
</div>
</div>
<% end %>
</div>
<% "backups" -> %>
<%= if false and @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">
{t("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">
{t("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>
{t("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={
t(
"Are you sure you want to delete this backup? This action cannot be undone."
)
}
variant="danger"
>
<.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">
{t("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"
>
{t("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">
{t("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">
{t("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">
{t("MikroTik API not enabled")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t("Enable MikroTik API in device settings to enable configuration backups.")}
</p>
<div class="mt-6">
<.button navigate={~p"/devices/#{@device.id}/edit"}>
{t("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">{t("Event Logs")}</h3>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{t("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">
{t("No events yet")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t(
"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">
{t("Raw Discovery Data")}
</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
<p>
{t("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">
{t("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">
{t("Field")}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("OID")}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("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">
{t("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">
{t("OID")}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("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">
{t("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">
{t("Type")}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Description")}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("OID")}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Value")}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("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">
{t("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">
{t(
"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">
{t("No checks configured")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t(
"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"
>
{t("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"
>
{t("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"
>
{t("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"
>
{t("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 dark:text-gray-400">
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 dark:text-gray-400">
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"
>
{t("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">
{t("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">
{t("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">
{t("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">
{t("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">
{t("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">
{t("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" />
{t("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">
{t("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">
{t("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">
{t("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">
{t("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">
{t("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">
{t("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">
{t("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"
>
{t("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"
>
{t("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"
>
{t("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">
{t("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">
{t("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">
{t("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">
{t("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">
{t("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">
{t("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">
{t("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">
{t("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">
{t("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">
{t("Time")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("Latency (ms)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("P95 Latency")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("Jitter (ms)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("Loss (%)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("Throughput")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("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">
{t("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>