3664 lines
185 KiB
Text
3664 lines
185 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="devices"
|
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
|
>
|
|
<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:-ml-4 lg:-mr-8 lg:pl-4 lg:pr-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 gap-2">
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<h1 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white truncate">
|
|
{@device.name}
|
|
</h1>
|
|
<div class={[
|
|
"inline-flex shrink-0 items-center gap-2 px-2.5 py-1 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 flex-wrap items-center gap-1.5 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
<span
|
|
class="font-mono text-gray-600 dark:text-gray-400 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 w-3 inline ml-0.5 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="inline-flex items-center gap-1.5">
|
|
<span class="text-xs text-gray-400 dark:text-gray-500 font-medium">
|
|
{t("Agent:")}
|
|
</span>
|
|
<%= if @agent_info.type == :cloud do %>
|
|
<span class="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400">
|
|
<.icon name="hero-cloud" class="h-3 w-3" />
|
|
<%= if @agent_info.agent_token_id do %>
|
|
{t("Cloud")} ({@agent_info.name})
|
|
<% else %>
|
|
{t("Cloud")}
|
|
<% end %>
|
|
</span>
|
|
<% else %>
|
|
<%= if @agent_info.is_offline do %>
|
|
<span
|
|
class="inline-flex items-center gap-1 px-1.5 py-0.5 font-medium text-amber-700 bg-amber-100 dark:text-amber-400 dark:bg-amber-900/30 rounded"
|
|
title="Agent has not checked in for over 5 minutes."
|
|
>
|
|
<.icon name="hero-exclamation-triangle" class="h-3 w-3" />
|
|
{@agent_info.name} · Offline
|
|
</span>
|
|
<% else %>
|
|
<span class="inline-flex items-center gap-1 text-green-600 dark:text-green-400">
|
|
<.icon name="hero-server" class="h-3 w-3" />
|
|
{@agent_info.name}
|
|
</span>
|
|
<% end %>
|
|
<% end %>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="shrink-0 mt-0.5">
|
|
<.button navigate={~p"/devices/#{@device.id}/edit"}>
|
|
<.icon name="hero-pencil" class="h-4 w-4" />
|
|
<span class="hidden sm:inline">{t("Edit")}</span>
|
|
</.button>
|
|
</div>
|
|
</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 %>
|
|
|
|
<%= if assigns[:wireless_clients_available] do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=wireless"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors inline-flex items-center gap-1.5",
|
|
if @active_tab == "wireless" 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("Wireless")}
|
|
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-300">
|
|
{@wireless_client_count}
|
|
</span>
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%= if assigns[:transceivers_available] do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=transceivers"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
|
|
if @active_tab == "transceivers" 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("Transceivers")}
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%= if assigns[:printer_supplies_available] do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=printer_supplies"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
|
|
if @active_tab == "printer_supplies" 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("Printer Supplies")}
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%= if assigns[:hardware_inventory_available] do %>
|
|
<.link
|
|
patch={~p"/devices/#{@device.id}?tab=hardware"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
|
|
if @active_tab == "hardware" 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("Hardware Inventory")}
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%= if @device.snmp_enabled do %>
|
|
<.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>
|
|
<% end %>
|
|
|
|
<%= 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
|
System Name
|
|
</dt>
|
|
<dd class="flex-1 text-right ml-4 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
|
{t("Resolved IP")}
|
|
</dt>
|
|
<dd
|
|
class="flex-1 text-right ml-4 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">Hardware</dt>
|
|
<dd class="flex-1 text-right ml-4 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
|
Operating System
|
|
</dt>
|
|
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
|
|
<div class="flex items-center justify-end 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
|
Serial Number
|
|
</dt>
|
|
<dd class="flex-1 text-right ml-4 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">Object ID</dt>
|
|
<dd class="flex-1 text-right ml-4 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">Contact</dt>
|
|
<dd class="flex-1 text-right ml-4 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">Location</dt>
|
|
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
|
|
{format_location(@snmp_device.sys_location)}
|
|
</dd>
|
|
</div>
|
|
|
|
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
|
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Uptime</dt>
|
|
<dd class="flex-1 text-right ml-4 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">Name</dt>
|
|
<dd class="flex-1 text-right ml-4 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
|
IP Address
|
|
</dt>
|
|
<dd class="flex-1 text-right ml-4 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
|
Device Added
|
|
</dt>
|
|
<dd class="flex-1 text-right ml-4 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
|
Last Discovered
|
|
</dt>
|
|
<dd class="flex-1 text-right ml-4 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="shrink-0 text-sm text-gray-600 dark:text-gray-400">Last Polled</dt>
|
|
<dd class="flex-1 text-right ml-4 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"
|
|
phx-update="ignore"
|
|
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"
|
|
phx-update="ignore"
|
|
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 text-xs !px-2 !py-0 text-gray-700 dark:text-gray-300"
|
|
>
|
|
{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"
|
|
phx-update="ignore"
|
|
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"
|
|
phx-update="ignore"
|
|
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="shrink-0 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="flex-1 text-right ml-4 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"
|
|
phx-update="ignore"
|
|
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="shrink-0 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="flex-1 text-right ml-4 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="shrink-0 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="flex-1 text-right ml-4 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="shrink-0 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="flex-1 text-right ml-4 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="shrink-0 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="flex-1 text-right ml-4 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="shrink-0 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="flex-1 text-right ml-4 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="shrink-0 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="flex-1 text-right ml-4 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 %>
|
|
{:erlang.float_to_binary(sensor.latest_reading.value * 1.0,
|
|
decimals: 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-right 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>
|
|
<th class="px-3 py-2 text-right font-medium">Errors</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-right 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
|
|
id={"clear-capacity-#{interface.id}"}
|
|
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
|
|
id={"set-capacity-#{interface.id}"}
|
|
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>
|
|
<td class="px-3 py-2 text-right font-mono text-xs">
|
|
<% total_errors =
|
|
interface.latest_stat &&
|
|
(interface.latest_stat.if_in_errors || 0) +
|
|
(interface.latest_stat.if_out_errors || 0) +
|
|
(interface.latest_stat.if_in_discards || 0) +
|
|
(interface.latest_stat.if_out_discards || 0) %>
|
|
<%= if total_errors && total_errors > 0 do %>
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{@device.id}/graph/interface_errors?interface_id=#{interface.id}"
|
|
}
|
|
class="text-red-600 dark:text-red-400 hover:underline"
|
|
>
|
|
{total_errors}
|
|
</.link>
|
|
<% else %>
|
|
<span class="text-gray-400">-</span>
|
|
<% end %>
|
|
</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 %>
|
|
<% "wireless" -> %>
|
|
<%= if @wireless_clients && length(@wireless_clients) > 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 flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t("Connected Wireless Clients")}
|
|
</h3>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{length(@wireless_clients)} clients — {@wireless_matched_count} subscribers matched
|
|
<%= if @wireless_last_updated do %>
|
|
· Last updated {format_relative_time(@wireless_last_updated)}
|
|
<% end %>
|
|
</p>
|
|
</div>
|
|
</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">MAC Address</th>
|
|
<th class="px-3 py-2 text-left font-medium">IP Address</th>
|
|
<th class="px-3 py-2 text-left font-medium">Subscriber</th>
|
|
<th class="px-3 py-2 text-left font-medium">Signal</th>
|
|
<th class="px-3 py-2 text-left font-medium">SNR</th>
|
|
<th class="px-3 py-2 text-right font-medium">Distance</th>
|
|
<th class="px-3 py-2 text-right font-medium">TX Rate</th>
|
|
<th class="px-3 py-2 text-right font-medium">RX Rate</th>
|
|
<th class="px-3 py-2 text-right font-medium">Uptime</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for client <- @wireless_clients do %>
|
|
<% subscriber_link =
|
|
Map.get(@wireless_client_subscribers, String.downcase(client.mac_address)) %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
|
<td class="px-3 py-2 font-mono text-xs">
|
|
<span
|
|
class="cursor-pointer group/mac text-gray-900 dark:text-white"
|
|
phx-click={JS.dispatch("phx:copy", detail: %{text: client.mac_address})}
|
|
title={t("Click to copy")}
|
|
>
|
|
{client.mac_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>
|
|
</td>
|
|
<td class="px-3 py-2 font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
<%= if client.ip_address do %>
|
|
<span
|
|
class="cursor-pointer group/ip"
|
|
phx-click={
|
|
JS.dispatch("phx:copy", detail: %{text: client.ip_address})
|
|
}
|
|
title={t("Click to copy")}
|
|
>
|
|
{client.ip_address}
|
|
<.icon
|
|
name="hero-clipboard-document"
|
|
class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/ip:opacity-100 transition-opacity text-gray-400"
|
|
/>
|
|
</span>
|
|
<% else %>
|
|
<span class="text-gray-400">—</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-3 py-2 text-xs">
|
|
<%= if subscriber_link && subscriber_link.gaiia_account do %>
|
|
<span class="text-gray-900 dark:text-white font-medium">
|
|
{subscriber_link.gaiia_account.account_name}
|
|
</span>
|
|
<% else %>
|
|
<span class="text-gray-400">—</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-3 py-2 text-xs">
|
|
<%= if client.signal_strength do %>
|
|
<.signal_badge value={client.signal_strength} />
|
|
<% else %>
|
|
<span class="text-gray-400">—</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-3 py-2 text-xs">
|
|
<%= if client.snr do %>
|
|
<.snr_badge value={client.snr} />
|
|
<% else %>
|
|
<span class="text-gray-400">—</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-3 py-2 text-right font-mono text-xs text-gray-900 dark:text-white">
|
|
<%= if client.distance do %>
|
|
{client.distance}m
|
|
<% else %>
|
|
<span class="text-gray-400">—</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-3 py-2 text-right font-mono text-xs text-gray-900 dark:text-white">
|
|
{format_rate(client.tx_rate)}
|
|
</td>
|
|
<td class="px-3 py-2 text-right font-mono text-xs text-gray-900 dark:text-white">
|
|
{format_rate(client.rx_rate)}
|
|
</td>
|
|
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
<%= if client.uptime_seconds do %>
|
|
{format_uptime(client.uptime_seconds * 100)}
|
|
<% else %>
|
|
<span class="text-gray-400">—</span>
|
|
<% end %>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="text-center py-12">
|
|
<.icon
|
|
name="hero-signal"
|
|
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-600"
|
|
/>
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
{t("No wireless clients")}
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("No clients are currently connected to this access point.")}
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
<% "transceivers" -> %>
|
|
<%= if @transceivers && length(@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("Optical Transceivers")}
|
|
</h3>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{length(@transceivers)} {if length(@transceivers) == 1,
|
|
do: "transceiver",
|
|
else: "transceivers"} installed
|
|
</p>
|
|
</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">Port</th>
|
|
<th class="px-4 py-3 text-left font-medium">Type</th>
|
|
<th class="px-4 py-3 text-left font-medium">Vendor</th>
|
|
<th class="px-4 py-3 text-left font-medium">Part Number</th>
|
|
<th class="px-4 py-3 text-left font-medium">Serial Number</th>
|
|
<th class="px-4 py-3 text-right font-medium">Wavelength</th>
|
|
<th class="px-4 py-3 text-center font-medium">DOM</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for transceiver <- @transceivers do %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
|
<td class="px-4 py-3 font-mono text-xs text-gray-900 dark:text-white">
|
|
{transceiver.port_index}
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<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/30 dark:text-blue-400">
|
|
{transceiver.transceiver_type || "Unknown"}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">
|
|
{transceiver.vendor_name || "—"}
|
|
</td>
|
|
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
{transceiver.vendor_part_number || "—"}
|
|
</td>
|
|
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
{transceiver.vendor_serial_number || "—"}
|
|
</td>
|
|
<td class="px-4 py-3 text-right font-mono text-xs text-gray-900 dark:text-white">
|
|
<%= if transceiver.wavelength_nm do %>
|
|
{transceiver.wavelength_nm} nm
|
|
<% else %>
|
|
<span class="text-gray-400">—</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-4 py-3 text-center">
|
|
<%= if transceiver.supports_dom 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/30 dark:text-green-400">
|
|
DOM
|
|
</span>
|
|
<% else %>
|
|
<span class="text-gray-400 text-xs">—</span>
|
|
<% end %>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="text-center py-12">
|
|
<.icon
|
|
name="hero-cpu-chip"
|
|
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-600"
|
|
/>
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
{t("No transceivers found")}
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("No optical transceivers detected on this device.")}
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
<% "printer_supplies" -> %>
|
|
<%= if @printer_supplies && length(@printer_supplies) > 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("Printer Supplies")}
|
|
</h3>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{length(@printer_supplies)} {if length(@printer_supplies) == 1,
|
|
do: "supply",
|
|
else: "supplies"} monitored
|
|
</p>
|
|
</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">Type</th>
|
|
<th class="px-4 py-3 text-left font-medium">Description</th>
|
|
<th class="px-4 py-3 text-left font-medium">Color</th>
|
|
<th class="px-4 py-3 text-left font-medium">Part Number</th>
|
|
<th class="px-4 py-3 text-right font-medium">Level</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for supply <- @printer_supplies do %>
|
|
<% percent =
|
|
if supply.max_capacity && supply.max_capacity > 0 do
|
|
round(supply.current_level * 100 / supply.max_capacity)
|
|
else
|
|
nil
|
|
end %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
|
<td class="px-4 py-3">
|
|
<span class={[
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
|
case supply.supply_type do
|
|
"toner" ->
|
|
"bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"
|
|
|
|
"ink" ->
|
|
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
|
|
|
"drum" ->
|
|
"bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
|
|
|
|
_ ->
|
|
"bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"
|
|
end
|
|
]}>
|
|
{supply.supply_type || "Unknown"}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">
|
|
{supply.supply_description || "—"}
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
{supply.color_name || "—"}
|
|
</td>
|
|
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
{supply.part_number || "—"}
|
|
</td>
|
|
<td class="px-4 py-3 text-right">
|
|
<%= if percent do %>
|
|
<div class="flex items-center justify-end gap-2">
|
|
<div class="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
<div
|
|
class={[
|
|
"h-full transition-all",
|
|
cond do
|
|
percent < 20 -> "bg-red-500"
|
|
percent < 40 -> "bg-yellow-500"
|
|
true -> "bg-green-500"
|
|
end
|
|
]}
|
|
style={"width: #{percent}%"}
|
|
>
|
|
</div>
|
|
</div>
|
|
<span class={[
|
|
"text-xs font-medium",
|
|
cond do
|
|
percent < 20 -> "text-red-600 dark:text-red-400"
|
|
percent < 40 -> "text-yellow-600 dark:text-yellow-400"
|
|
true -> "text-green-600 dark:text-green-400"
|
|
end
|
|
]}>
|
|
{percent}%
|
|
</span>
|
|
</div>
|
|
<% else %>
|
|
<span class="text-gray-400 text-xs">—</span>
|
|
<% end %>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="text-center py-12">
|
|
<.icon name="hero-printer" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-600" />
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
{t("No printer supplies found")}
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("No printer supplies detected on this device.")}
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
<% "hardware" -> %>
|
|
<%= if @hardware_inventory && length(@hardware_inventory) > 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("Hardware Inventory")}
|
|
</h3>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{length(@hardware_inventory)} {if length(@hardware_inventory) == 1,
|
|
do: "component",
|
|
else: "components"} detected
|
|
</p>
|
|
</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">Class</th>
|
|
<th class="px-4 py-3 text-left font-medium">Name</th>
|
|
<th class="px-4 py-3 text-left font-medium">Description</th>
|
|
<th class="px-4 py-3 text-left font-medium">Serial Number</th>
|
|
<th class="px-4 py-3 text-left font-medium">Model</th>
|
|
<th class="px-4 py-3 text-left font-medium">Manufacturer</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for entity <- @hardware_inventory do %>
|
|
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
|
<td class="px-4 py-3">
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
|
|
{entity.entity_class || "Unknown"}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
|
|
{entity.name || "—"}
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
{entity.description || "—"}
|
|
</td>
|
|
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
{entity.serial_number || "—"}
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
{entity.model || "—"}
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
{entity.manufacturer || "—"}
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="text-center py-12">
|
|
<.icon
|
|
name="hero-cpu-chip"
|
|
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-600"
|
|
/>
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
{t("No hardware inventory found")}
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("No hardware components detected on this device.")}
|
|
</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 text-sm text-left">
|
|
{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">
|
|
<div class="flex items-center justify-end gap-3">
|
|
<.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>
|
|
<%= if check.check_type in ["http", "tcp", "dns", "ssl"] do %>
|
|
<button
|
|
type="button"
|
|
phx-click="edit_check"
|
|
phx-value-id={check.id}
|
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
|
>
|
|
<.icon name="hero-pencil-square" class="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
phx-click="delete_check"
|
|
phx-value-id={check.id}
|
|
data-confirm="Are you sure you want to delete this check?"
|
|
class="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
|
>
|
|
<.icon name="hero-trash" class="h-4 w-4" />
|
|
</button>
|
|
<% end %>
|
|
</div>
|
|
</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={if @edit_check, do: @edit_check.id, else: :new}
|
|
action={if @edit_check, do: :edit, else: :new}
|
|
check={@edit_check}
|
|
device={@device}
|
|
/>
|
|
<% end %>
|
|
</Layouts.authenticated>
|