ui: polish auth pages, core components, and visual consistency
This commit is contained in:
parent
d022a0ee87
commit
5041ac8e25
6 changed files with 639 additions and 328 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue