ui: polish auth pages, core components, and visual consistency

This commit is contained in:
Graham McIntire 2026-02-14 21:08:32 -06:00
parent d022a0ee87
commit 5041ac8e25
6 changed files with 639 additions and 328 deletions

View file

@ -154,3 +154,30 @@ tbody tr:hover td { background-color: rgba(59, 130, 246, 0.04); }
outline: 2px solid oklch(58% 0.233 277.117);
outline-offset: 2px;
}
/* Global smooth transitions for interactive elements */
a, button, input, select, textarea {
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
/* Dark mode body transition */
body, .bg-gray-50, .bg-white, .dark\:bg-gray-950, .dark\:bg-gray-900 {
transition: background-color 0.2s ease;
}
/* Password strength indicator (JS-driven via classes) */
.password-strength-bar.strength-weak { background-color: #ef4444; }
.password-strength-bar.strength-fair { background-color: #f59e0b; }
.password-strength-bar.strength-good { background-color: #22c55e; }
.password-strength-bar.strength-strong { background-color: #16a34a; }
/* Skeleton shimmer effect */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-shimmer {
background: linear-gradient(90deg, transparent 25%, rgba(255,255,255,0.08) 50%, transparent 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}

View file

@ -134,21 +134,36 @@ defmodule ToweropsWeb.CoreComponents do
"""
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary danger)
attr :variant, :string, values: ~w(primary secondary danger ghost)
attr :size, :string, default: "md", values: ~w(sm md lg)
attr :loading, :boolean, default: false
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{
"primary" => "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600",
"danger" => "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 dark:bg-red-500 dark:hover:bg-red-600",
"primary" =>
"bg-blue-600 text-white hover:bg-blue-500 active:bg-blue-700 focus:ring-blue-500 shadow-md hover:shadow-lg dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-600",
"secondary" =>
"bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-200 dark:ring-gray-600 dark:hover:bg-gray-700",
"danger" =>
"bg-red-600 text-white hover:bg-red-500 active:bg-red-700 focus:ring-red-500 dark:bg-red-500 dark:hover:bg-red-400",
"ghost" =>
"bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-blue-500 shadow-none dark:text-gray-300 dark:hover:bg-gray-800",
nil =>
"bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:ring-blue-500 dark:bg-gray-800 dark:text-white dark:ring-gray-700 dark:hover:bg-gray-700"
}
sizes = %{
"sm" => "px-2.5 py-1.5 text-xs",
"md" => "px-3.5 py-2.5 text-sm",
"lg" => "px-5 py-3 text-base"
}
assigns =
assign_new(assigns, :class, fn ->
[
"inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed",
"inline-flex items-center justify-center gap-2 rounded-lg font-semibold shadow-sm transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-gray-900",
Map.fetch!(sizes, assigns.size),
Map.fetch!(variants, assigns[:variant])
]
end)
@ -156,12 +171,44 @@ defmodule ToweropsWeb.CoreComponents do
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
<svg
:if={@loading}
class="animate-spin -ml-1 size-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
</circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
<button class={@class} disabled={@loading || @rest[:disabled]} {@rest}>
<svg
:if={@loading}
class="animate-spin -ml-1 size-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
</circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
{render_slot(@inner_block)}
</button>
"""
@ -497,7 +544,11 @@ defmodule ToweropsWeb.CoreComponents do
phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}
class="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-gray-800/50"
>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class="transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/80"
>
<td
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}

View file

@ -4,8 +4,17 @@ defmodule ToweropsWeb.Components.Skeletons do
"""
use Phoenix.Component
# Reusable skeleton block — a single pulsing bar
defp skel(assigns) do
assigns = assign_new(assigns, :class, fn -> "" end)
~H"""
<div class={["rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer", @class]}></div>
"""
end
@doc """
A pulsing card placeholder.
A pulsing card placeholder matching stat cards.
"""
attr :class, :string, default: nil
@ -15,9 +24,9 @@ defmodule ToweropsWeb.Components.Skeletons do
"animate-pulse rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50",
@class
]}>
<div class="h-3 w-24 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="mt-3 h-8 w-16 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="mt-2 h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
<.skel class="h-3 w-24" />
<.skel class="mt-3 h-8 w-16" />
<.skel class="mt-2 h-3 w-20" />
</div>
"""
end
@ -30,18 +39,33 @@ defmodule ToweropsWeb.Components.Skeletons do
def skeleton_table(assigns) do
~H"""
<div class="animate-pulse rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="border-b border-gray-200 px-4 py-3 dark:border-white/10">
<div class="flex gap-6">
<div :for={_ <- 1..@cols} class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<!-- Header -->
<div class="border-b border-gray-200 bg-gray-50 px-6 py-3.5 dark:border-white/10 dark:bg-gray-800/75">
<div class="flex gap-8">
<div
:for={_ <- 1..@cols}
class="h-3 w-20 animate-pulse rounded bg-gray-300 dark:bg-gray-600"
>
</div>
</div>
</div>
<!-- Rows -->
<div
:for={_ <- 1..@rows}
class="border-b border-gray-100 px-4 py-3 last:border-0 dark:border-white/5"
:for={i <- 1..@rows}
class={[
"border-b border-gray-100 px-6 py-4 last:border-0 dark:border-white/5",
rem(i, 2) == 0 && "bg-gray-50/50 dark:bg-gray-800/25"
]}
>
<div class="flex gap-6">
<div :for={_ <- 1..@cols} class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="flex items-center gap-8">
<div class="h-3 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer">
</div>
<div
:for={_ <- 2..@cols}
class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer"
>
</div>
</div>
</div>
</div>
@ -59,47 +83,72 @@ defmodule ToweropsWeb.Components.Skeletons do
"animate-pulse rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50",
@class
]}>
<div class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="mt-2 h-8 w-12 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="flex items-center gap-2">
<div class="h-5 w-5 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer"></div>
<div class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer"></div>
</div>
<div class="mt-2 h-8 w-16 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer"></div>
<div class="mt-1.5 h-2.5 w-14 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer"></div>
</div>
"""
end
@doc """
Dashboard loading skeleton - full placeholder for the dashboard content.
Dashboard loading skeleton mirrors the real dashboard layout.
"""
def skeleton_dashboard(assigns) do
~H"""
<!-- Stat cards row -->
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<.skeleton_stat :for={_ <- 1..6} />
</div>
<!-- Filter bar -->
<div class="mt-6 flex flex-wrap items-center gap-3">
<div :for={_ <- 1..4} class="h-8 w-28 animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700">
<div
:for={w <- [28, 24, 32, 20]}
class={[
"h-9 animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700 skeleton-shimmer",
"w-#{w}"
]}
>
</div>
</div>
<!-- Two-column content area -->
<div class="mt-8 grid gap-8 lg:grid-cols-5">
<!-- Main column: alerts/activity -->
<div class="lg:col-span-3">
<div class="h-5 w-28 animate-pulse rounded bg-gray-200 dark:bg-gray-700 mb-4"></div>
<div class="h-5 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer mb-4">
</div>
<div class="space-y-3">
<div
:for={_ <- 1..3}
class="animate-pulse rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-gray-800/50"
>
<div class="h-3 w-32 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="mt-2 h-4 w-48 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="mt-2 h-3 w-64 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="flex items-center gap-3">
<div class="h-8 w-8 rounded-full bg-gray-200 dark:bg-gray-700 skeleton-shimmer"></div>
<div class="flex-1">
<div class="h-3.5 w-40 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer"></div>
<div class="mt-2 h-3 w-64 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer">
</div>
</div>
<div class="h-3 w-12 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer"></div>
</div>
</div>
</div>
</div>
<!-- Sidebar column: recent/quick stats -->
<div class="lg:col-span-2">
<div class="h-5 w-20 animate-pulse rounded bg-gray-200 dark:bg-gray-700 mb-4"></div>
<div class="h-5 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer mb-4">
</div>
<div class="space-y-3">
<div
:for={_ <- 1..3}
:for={_ <- 1..4}
class="animate-pulse rounded-lg border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-800/50"
>
<div class="h-3 w-16 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="mt-2 h-4 w-40 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="flex items-center justify-between">
<div class="h-3 w-28 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer"></div>
<div class="h-5 w-10 rounded-full bg-gray-200 dark:bg-gray-700 skeleton-shimmer"></div>
</div>
</div>
</div>
</div>

View file

@ -1,139 +1,212 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
<%= if @invitation do %>
{t_auth("Join %{organization}", organization: @invitation.organization.name)}
<% else %>
{t_auth("Register for an account")}
<% end %>
<:subtitle>
{t_auth("Already registered?")}
<.link
navigate={~p"/users/log-in"}
class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{t_auth("Log in")}
</.link>
{t_auth("to your account now.")}
</:subtitle>
</.header>
<%= if @invitation do %>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
<p class="text-sm text-blue-900 dark:text-blue-100">
{t_auth("You've been invited to join %{organization} as a %{role}.",
organization: @invitation.organization.name,
role: @invitation.role
)}
<div class="mx-auto max-w-4xl">
<div class="overflow-hidden rounded-2xl bg-white shadow-xl dark:bg-gray-900 lg:grid lg:grid-cols-5">
<!-- Left: Brand panel -->
<div class="hidden lg:col-span-2 lg:flex lg:flex-col lg:justify-between bg-gradient-to-br from-blue-600 via-blue-700 to-indigo-800 p-8 text-white">
<div>
<div class="flex items-center gap-2">
<.icon name="hero-signal" class="size-8" />
<span class="text-xl font-bold">TowerOps</span>
</div>
<p class="mt-6 text-lg font-medium leading-relaxed text-blue-100">
{t("Start monitoring your network in minutes.")}
</p>
</div>
<% else %>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
{t_auth("Free tier includes:")}
</p>
<ul class="mt-2 text-sm text-blue-800 dark:text-blue-200 space-y-1">
<li>{t_auth("✓ Monitor up to 10 devices")}</li>
<li>{t_auth("✓ Real-time alerts and notifications")}</li>
<li>{t_auth("✓ Performance charts and historical data")}</li>
<li>{t_auth("✓ No credit card required")}</li>
</ul>
<div class="mt-auto space-y-4">
<div class="rounded-lg bg-white/10 p-4 backdrop-blur-sm">
<p class="text-sm font-semibold text-white">{t("Free tier includes:")}</p>
<ul class="mt-2 space-y-1.5 text-sm text-blue-100">
<li class="flex items-center gap-2">
<.icon name="hero-check" class="size-4 text-green-300" />
{t("Monitor up to 10 devices")}
</li>
<li class="flex items-center gap-2">
<.icon name="hero-check" class="size-4 text-green-300" />
{t("Real-time alerts")}
</li>
<li class="flex items-center gap-2">
<.icon name="hero-check" class="size-4 text-green-300" />
{t("Performance charts & history")}
</li>
<li class="flex items-center gap-2">
<.icon name="hero-check" class="size-4 text-green-300" />
{t("No credit card required")}
</li>
</ul>
</div>
</div>
</div>
<!-- Right: Registration form -->
<div class="col-span-3 px-6 py-10 sm:px-12">
<!-- Mobile brand mark -->
<div class="mb-6 flex items-center justify-center gap-2 lg:hidden">
<.icon name="hero-signal" class="size-7 text-blue-600 dark:text-blue-400" />
<span class="text-lg font-bold text-gray-900 dark:text-white">TowerOps</span>
</div>
<% end %>
</div>
<div class="mt-8">
<.form :let={f} for={@form} action={~p"/users/register"}>
<%= if @invitation_token do %>
<input type="hidden" name="user[invitation_token]" value={@invitation_token} />
<% end %>
<.input
field={f[:email]}
type="email"
label={t_auth("Email")}
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.input
field={f[:password]}
type="password"
label={t_auth("Password")}
autocomplete="new-password"
required
/>
<div class="text-center lg:text-left">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
<%= if @invitation do %>
{t_auth("Join %{organization}", organization: @invitation.organization.name)}
<% else %>
{t_auth("Create your account")}
<% end %>
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{t_auth("Already registered?")}
<.link
navigate={~p"/users/log-in"}
class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{t_auth("Log in")}
</.link>
</p>
</div>
<!-- Mobile: value prop banner -->
<%= unless @invitation do %>
<.input
name="user[organization_name]"
value=""
type="text"
label={t_auth("Organization Name")}
placeholder={t_auth("My Company")}
required
/>
<div class="mt-5 flex items-center gap-2 rounded-lg bg-green-50 px-4 py-2.5 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-300 lg:hidden">
<.icon name="hero-gift" class="size-5 flex-shrink-0" />
<span class="font-medium">
{t_auth("First 10 devices free — no credit card needed")}
</span>
</div>
<% end %>
<div class="space-y-3 mt-6">
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="privacy_policy_consent"
name="user[privacy_policy_consent]"
type="checkbox"
required
class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:checked:bg-blue-500 dark:checked:border-blue-500"
/>
</div>
<div class="ml-3 text-sm">
<label for="privacy_policy_consent" class="text-zinc-700 dark:text-zinc-300">
{t_auth("I agree to the %{link}",
link:
raw(
~s(<a href="/privacy" target="_blank" class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">#{t_auth("Privacy Policy")}</a>)
)
)}
<span class="text-red-600 dark:text-red-400">*</span>
</label>
</div>
<%= if @invitation do %>
<div class="mt-5 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
<p class="text-sm text-blue-900 dark:text-blue-100">
{t_auth("You've been invited to join %{organization} as a %{role}.",
organization: @invitation.organization.name,
role: @invitation.role
)}
</p>
</div>
<% end %>
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="terms_of_service_consent"
name="user[terms_of_service_consent]"
type="checkbox"
<div class="mt-8">
<.form :let={f} for={@form} action={~p"/users/register"}>
<%= if @invitation_token do %>
<input type="hidden" name="user[invitation_token]" value={@invitation_token} />
<% end %>
<.input
field={f[:email]}
type="email"
label={t_auth("Email")}
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.input
field={f[:password]}
type="password"
label={t_auth("Password")}
autocomplete="new-password"
required
/>
<!-- Password strength indicator (CSS-only) -->
<div class="mb-4 -mt-2">
<div class="flex gap-1">
<div
class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 password-strength-bar"
data-index="1"
>
</div>
<div
class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 password-strength-bar"
data-index="2"
>
</div>
<div
class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 password-strength-bar"
data-index="3"
>
</div>
<div
class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 password-strength-bar"
data-index="4"
>
</div>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-500">
{t_auth("Use 12+ characters for a strong password")}
</p>
</div>
<%= unless @invitation do %>
<.input
name="user[organization_name]"
value=""
type="text"
label={t_auth("Organization Name")}
placeholder={t_auth("My Company")}
required
class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:checked:bg-blue-500 dark:checked:border-blue-500"
/>
<% end %>
<div class="space-y-3 mt-6">
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="privacy_policy_consent"
name="user[privacy_policy_consent]"
type="checkbox"
required
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:checked:bg-blue-500 dark:checked:border-blue-500"
/>
</div>
<div class="ml-3 text-sm">
<label for="privacy_policy_consent" class="text-gray-700 dark:text-gray-300">
{t_auth("I agree to the %{link}",
link:
raw(
~s(<a href="/privacy" target="_blank" class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">#{t_auth("Privacy Policy")}</a>)
)
)}
<span class="text-red-600 dark:text-red-400">*</span>
</label>
</div>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="terms_of_service_consent"
name="user[terms_of_service_consent]"
type="checkbox"
required
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:checked:bg-blue-500 dark:checked:border-blue-500"
/>
</div>
<div class="ml-3 text-sm">
<label for="terms_of_service_consent" class="text-gray-700 dark:text-gray-300">
{t_auth("I agree to the %{link}",
link:
raw(
~s(<a href="/terms" target="_blank" class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">#{t_auth("Terms of Service")}</a>)
)
)}
<span class="text-red-600 dark:text-red-400">*</span>
</label>
</div>
</div>
</div>
<div class="ml-3 text-sm">
<label for="terms_of_service_consent" class="text-zinc-700 dark:text-zinc-300">
{t_auth("I agree to the %{link}",
link:
raw(
~s(<a href="/terms" target="_blank" class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">#{t_auth("Terms of Service")}</a>)
)
)}
<span class="text-red-600 dark:text-red-400">*</span>
</label>
</div>
</div>
<.button
phx-disable-with={t_auth("Creating account...")}
class="w-full mt-6"
variant="primary"
>
{if @invitation,
do: t_auth("Accept invitation and create account"),
else: t_auth("Create an account")}
<span aria-hidden="true">→</span>
</.button>
</.form>
</div>
<.button
phx-disable-with={t_auth("Creating account...")}
class="w-full mt-6"
variant="primary"
>
{if @invitation,
do: t_auth("Accept invitation and create account"),
else: t_auth("Create an account")}
</.button>
</.form>
</div>
</div>
</div>
</Layouts.app>

View file

@ -1,119 +1,168 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
{t_auth("Log in")}
<:subtitle>
<%= if @current_scope do %>
{t_auth("You need to reauthenticate to perform sensitive actions on your account.")}
<% else %>
{t_auth("Don't have an account?")}
<.link
navigate={~p"/users/register"}
class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{t_auth("Sign up")}
</.link>
{t_auth("for an account now.")}
<% end %>
</:subtitle>
</.header>
</div>
<div
:if={local_mail_adapter?()}
class="mt-6 rounded-lg bg-blue-50 p-4 dark:bg-blue-950"
>
<div class="flex">
<.icon
name="hero-information-circle"
class="h-6 w-6 flex-shrink-0 text-blue-600 dark:text-blue-400"
/>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-200">
{t_auth("You are running the local mail adapter.")}
</p>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-200">
{t_auth("To see sent emails, visit")}
<a href="/dev/mailbox" class="font-medium underline">
{t_auth("the mailbox page")}
</a>.
<div class="mx-auto max-w-4xl">
<div class="overflow-hidden rounded-2xl bg-white shadow-xl dark:bg-gray-900 lg:grid lg:grid-cols-5">
<!-- Left: Brand panel -->
<div class="hidden lg:col-span-2 lg:flex lg:flex-col lg:justify-between bg-gradient-to-br from-blue-600 via-blue-700 to-indigo-800 p-8 text-white">
<div>
<div class="flex items-center gap-2">
<.icon name="hero-signal" class="size-8" />
<span class="text-xl font-bold">TowerOps</span>
</div>
<p class="mt-6 text-lg font-medium leading-relaxed text-blue-100">
{t("Monitor your entire network from a single dashboard.")}
</p>
</div>
</div>
</div>
<div class="mt-8 space-y-6">
<!-- Password Login (Default) -->
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
id="user_email_password"
type="email"
label={t_auth("Email")}
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.input
field={f[:password]}
id="user_password"
type="password"
label={t_auth("Password")}
autocomplete="current-password"
required
/>
<div class="flex items-center justify-end">
<.link
navigate={~p"/users/reset-password"}
class="text-sm font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{t_auth("Forgot password?")}
</.link>
</div>
<div class="space-y-3">
<.button class="w-full" variant="primary" name={@form[:remember_me].name} value="true">
{t_auth("Log in")} <span aria-hidden="true">→</span>
</.button>
</div>
</.form>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-white/10"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-gray-50 px-2 text-gray-600 dark:bg-gray-950 dark:text-gray-400">
{t("or")}
</span>
<div class="mt-auto">
<div class="rounded-lg bg-white/10 p-4 backdrop-blur-sm">
<p class="text-sm italic text-blue-100">
{t(
"\"TowerOps cut our response time to network issues by 80%. We can see problems before customers even notice.\""
)}
</p>
<p class="mt-3 text-xs font-semibold text-blue-200">{t("— WISP Network Operator")}</p>
</div>
</div>
</div>
<!-- Alternative: Email Login Link -->
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
id="user_email_magic"
type="email"
label={t_auth("Email")}
autocomplete="email"
required
/>
<.button class="w-full">
{t_auth("Send me a login link instead")}
</.button>
</.form>
<!-- Right: Login form -->
<div class="col-span-3 px-6 py-10 sm:px-12">
<!-- Mobile brand mark -->
<div class="mb-6 flex items-center justify-center gap-2 lg:hidden">
<.icon name="hero-signal" class="size-7 text-blue-600 dark:text-blue-400" />
<span class="text-lg font-bold text-gray-900 dark:text-white">TowerOps</span>
</div>
<p class="text-center text-sm mt-4 text-gray-600 dark:text-gray-400">
<.link
href={~p"/users/confirm"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
<div class="text-center lg:text-left">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{t_auth("Log in")}
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<%= if @current_scope do %>
{t_auth("You need to reauthenticate to perform sensitive actions on your account.")}
<% else %>
{t_auth("Don't have an account?")}
<.link
navigate={~p"/users/register"}
class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{t_auth("Sign up")}
</.link>
<% end %>
</p>
</div>
<div
:if={local_mail_adapter?()}
class="mt-5 rounded-lg bg-blue-50 p-4 dark:bg-blue-950"
>
{t_auth("Didn't receive confirmation instructions?")}
</.link>
</p>
<div class="flex">
<.icon
name="hero-information-circle"
class="h-6 w-6 flex-shrink-0 text-blue-600 dark:text-blue-400"
/>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-200">
{t_auth("You are running the local mail adapter.")}
</p>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-200">
{t_auth("To see sent emails, visit")}
<a href="/dev/mailbox" class="font-medium underline">
{t_auth("the mailbox page")}
</a>.
</p>
</div>
</div>
</div>
<div class="mt-8 space-y-6">
<!-- Password Login -->
<.form
:let={f}
for={@form}
as={:user}
id="login_form_password"
action={~p"/users/log-in"}
>
<.input
readonly={!!@current_scope}
field={f[:email]}
id="user_email_password"
type="email"
label={t_auth("Email")}
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.input
field={f[:password]}
id="user_password"
type="password"
label={t_auth("Password")}
autocomplete="current-password"
required
/>
<div class="flex items-center justify-between">
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
name={@form[:remember_me].name}
value="true"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
/>
{t_auth("Remember me")}
</label>
<.link
navigate={~p"/users/reset-password"}
class="text-sm font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{t_auth("Forgot password?")}
</.link>
</div>
<div class="mt-4">
<.button class="w-full" variant="primary">
{t_auth("Log in")} <span aria-hidden="true">→</span>
</.button>
</div>
</.form>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200 dark:border-white/10"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-white px-3 text-gray-500 dark:bg-gray-900 dark:text-gray-500">
{t("or")}
</span>
</div>
</div>
<!-- Magic Link -->
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
id="user_email_magic"
type="email"
label={t_auth("Email")}
autocomplete="email"
required
/>
<.button class="w-full" variant="secondary">
<.icon name="hero-envelope" class="size-4" />
{t_auth("Send me a login link instead")}
</.button>
</.form>
<p class="text-center text-sm text-gray-500 dark:text-gray-500">
<.link
href={~p"/users/confirm"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 hover:underline"
>
{t_auth("Didn't receive confirmation instructions?")}
</.link>
</p>
</div>
</div>
</div>
</div>
</Layouts.app>

View file

@ -17,7 +17,7 @@
} />
<div class="sticky top-0 z-30 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 py-3 bg-gray-50/95 dark:bg-gray-950/95 backdrop-blur-sm border-b border-gray-200/50 dark:border-white/5 mb-4">
<div>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@device.name}</h1>
<div class={[
@ -100,15 +100,15 @@
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex space-x-8">
<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 font-medium text-sm",
"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"
"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"
"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
]}
>
@ -119,11 +119,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=ports"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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"
"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"
"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
]}
>
@ -134,11 +134,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=neighbors"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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"
"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"
"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
]}
>
@ -149,11 +149,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=arp"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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"
"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"
"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
]}
>
@ -165,11 +165,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=mac"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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"
"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"
"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
]}
>
@ -181,11 +181,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=vlans"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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"
"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"
"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
]}
>
@ -197,11 +197,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=ip_addresses"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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"
"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"
"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
]}
>
@ -213,11 +213,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=backups"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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"
"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"
"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
]}
>
@ -244,11 +244,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=preseem"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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",
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"
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
)
]}
>
@ -263,11 +263,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=gaiia"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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",
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"
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
)
]}
>
@ -283,11 +283,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=checks"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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"
"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"
"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
]}
>
@ -297,11 +297,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=logs"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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"
"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"
"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
]}
>
@ -312,11 +312,11 @@
<.link
patch={~p"/devices/#{@device.id}?tab=debug"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
"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"
"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"
"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
]}
>
@ -571,8 +571,16 @@
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 border-t-blue-500">
</div>
<span class="text-xs text-gray-400">{t("Loading chart...")}</span>
</div>
</div>
<canvas></canvas>
</div>
</div>
@ -599,8 +607,16 @@
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 border-t-blue-500">
</div>
<span class="text-xs text-gray-400">{t("Loading chart...")}</span>
</div>
</div>
<canvas></canvas>
</div>
</div>
@ -1243,54 +1259,64 @@
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th class="px-4 py-3 text-left font-medium">#</th>
<th class="px-4 py-3 text-left font-medium">Name</th>
<th class="px-4 py-3 text-left font-medium">Status</th>
<th class="px-4 py-3 text-left font-medium">Speed</th>
<th class="px-4 py-3 text-left font-medium">IP Address</th>
<th class="px-4 py-3 text-left font-medium">MAC</th>
<th class="px-3 py-2 text-left font-medium">#</th>
<th class="px-3 py-2 text-left font-medium">Name</th>
<th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-left font-medium">Speed</th>
<th class="px-3 py-2 text-left font-medium">IP Address</th>
<th class="px-3 py-2 text-left font-medium">MAC</th>
<th class="px-3 py-2 text-right font-medium">In</th>
<th class="px-3 py-2 text-right font-medium">Out</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for interface <- interfaces do %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 text-gray-900 dark:text-white">
<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-4 py-3">
<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">
<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">
<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-4 py-3">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
interface.if_oper_status == "up" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
interface.if_oper_status == "down" &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
interface.if_oper_status not in ["up", "down"] &&
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
]}>
{String.upcase(interface.if_oper_status || "unknown")}
<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-4 py-3 text-gray-900 dark:text-white">
<td class="px-3 py-2 text-gray-900 dark:text-white text-xs font-mono">
{format_speed(interface.if_speed)}
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
<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 %>
@ -1298,18 +1324,54 @@
<% else %>
<div class="space-y-0.5">
<%= for ip <- interface_ips do %>
<div>
<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-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
{interface.if_phys_address || "-"}
<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-right font-mono text-xs text-gray-600 dark:text-gray-400">
{if interface.if_in_octets,
do: format_bytes(interface.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.if_out_octets,
do: format_bytes(interface.if_out_octets),
else: "-"}
</td>
</tr>
<% end %>