ui: polish device detail, site detail, and user settings

This commit is contained in:
Graham McIntire 2026-02-14 21:10:57 -06:00
parent 5041ac8e25
commit e5796b3464
3 changed files with 325 additions and 221 deletions

View file

@ -180,9 +180,12 @@ defmodule ToweropsWeb.SiteLive.Show do
defp change_size_label(size) when size < 500, do: "Medium"
defp change_size_label(_), do: "Large"
defp change_size_color(size) when size < 100, do: "badge-ghost"
defp change_size_color(size) when size < 500, do: "badge-warning"
defp change_size_color(_), do: "badge-error"
defp change_size_color(size) when size < 100, do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
defp change_size_color(size) when size < 500,
do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
defp change_size_color(_), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
defp insight_urgency_class("critical"), do: "text-red-600 dark:text-red-400"
defp insight_urgency_class("warning"), do: "text-yellow-600 dark:text-yellow-400"

View file

@ -35,77 +35,93 @@
</div>
<% end %>
<.header>
{@page_title}
<:subtitle>{@site.location}</:subtitle>
<:actions>
<%= if @device != [] do %>
<.button
type="button"
phx-click="force_rediscover_all"
data-confirm={
t("This will trigger SNMP discovery for all devices at this site. Continue?")
}
>
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All
</.button>
<% end %>
<.button navigate={~p"/sites/#{@site.id}/edit"}>
<.icon name="hero-pencil" class="h-4 w-4" /> Edit
<%= if @device != [] do %>
<div class="mt-2">
<.button
type="button"
phx-click="force_rediscover_all"
class="btn-sm"
data-confirm={
t("This will trigger SNMP discovery for all devices at this site. Continue?")
}
>
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> {t("Force Rediscover All")}
</.button>
</:actions>
</.header>
</div>
<% end %>
<%!-- Quick Actions --%>
<div class="mt-4 flex flex-wrap gap-2">
<.button navigate={~p"/devices/new?site_id=#{@site.id}"} variant="primary" class="btn-sm">
<.icon name="hero-plus" class="h-4 w-4" /> {t("Add Device")}
</.button>
<%= if @site.latitude && @site.longitude do %>
<.button
navigate={~p"/sites-map?lat=#{@site.latitude}&lng=#{@site.longitude}&zoom=15"}
class="btn-sm"
>
<.icon name="hero-map" class="h-4 w-4" /> {t("View on Map")}
</.button>
<% end %>
<.button navigate={~p"/sites/#{@site.id}/edit"} class="btn-sm">
<.icon name="hero-pencil" class="h-4 w-4" /> {t("Edit Site")}
</.button>
</div>
<%!-- Summary Stat Cards --%>
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<%!-- Subscribers --%>
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<.icon name="hero-users" class="h-4 w-4" /> Subscribers
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{format_number(@site_summary.subscribers || 0)}
</p>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50 p-4 transition-shadow hover:shadow-sm">
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<.icon name="hero-users" class="h-4 w-4" /> {t("Subscribers")}
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white font-mono">
{format_number(@site_summary.subscribers || 0)}
</p>
</div>
<%!-- MRR --%>
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<.icon name="hero-currency-dollar" class="h-4 w-4" /> Monthly Revenue
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{format_mrr(@site_summary.mrr)}
</p>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50 p-4 transition-shadow hover:shadow-sm">
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<.icon name="hero-currency-dollar" class="h-4 w-4" /> {t("Monthly Revenue")}
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{format_mrr(@site_summary.mrr)}
</p>
</div>
<%!-- Device Count --%>
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<.icon name="hero-server" class="h-4 w-4" /> Devices
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{format_number(@site_summary.device_count)}
<%= if @site_summary.devices_down > 0 do %>
<span class="text-sm font-medium text-red-600 dark:text-red-400">
({@site_summary.devices_down} down)
</span>
<% end %>
</p>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50 p-4 transition-shadow hover:shadow-sm">
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<.icon name="hero-server" class="h-4 w-4" /> {t("Devices")}
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white font-mono">
{format_number(@site_summary.device_count)}
<%= if @site_summary.devices_down > 0 do %>
<span class="text-sm font-medium text-red-600 dark:text-red-400">
({@site_summary.devices_down} {t("down")})
</span>
<% end %>
</p>
</div>
<%!-- Active Alerts --%>
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<.icon name="hero-bell-alert" class="h-4 w-4" /> Active Alerts
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{length(@active_alerts)}
</p>
<div class={[
"rounded-lg border bg-white dark:bg-gray-800/50 p-4 transition-shadow hover:shadow-sm",
if(length(@active_alerts) > 0,
do: "border-red-200 dark:border-red-900/50",
else: "border-gray-200 dark:border-white/10"
)
]}>
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<.icon name="hero-bell-alert" class="h-4 w-4" /> {t("Active Alerts")}
</div>
<p class={[
"mt-1 text-2xl font-bold font-mono",
if(length(@active_alerts) > 0,
do: "text-red-600 dark:text-red-400",
else: "text-gray-900 dark:text-white"
)
]}>
{length(@active_alerts)}
</p>
</div>
</div>
@ -145,9 +161,11 @@
<%!-- Left Column --%>
<div class="space-y-6">
<%!-- Site Details --%>
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<h3 class="card-title text-base">{t("Site Details")}</h3>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
{t("Site Details")}
</h3>
<dl class="mt-2 space-y-3">
<%= if @site.parent_site do %>
<div>
@ -193,12 +211,14 @@
</div>
<%!-- Active Alerts Panel --%>
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<h3 class="card-title text-base">
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<.icon name="hero-bell-alert" class="h-5 w-5" /> Active Alerts
<%= if @active_alerts != [] do %>
<span class="badge badge-error badge-sm">{length(@active_alerts)}</span>
<span class="ml-2 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400">
{length(@active_alerts)}
</span>
<% end %>
</h3>
<%= if @active_alerts == [] do %>
@ -210,7 +230,7 @@
<div class="mt-2 space-y-2">
<div
:for={alert <- @active_alerts}
class="flex items-start gap-3 rounded-lg border border-base-300 p-3"
class="flex items-start gap-3 rounded-lg border border-gray-200 dark:border-white/10 p-3"
>
<.icon
name={insight_urgency_icon(alert_severity(alert.alert_type))}
@ -232,9 +252,9 @@
</div>
<%!-- Recent Config Changes Panel --%>
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<h3 class="card-title text-base">
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<.icon name="hero-document-text" class="h-5 w-5" /> Recent Config Changes
</h3>
<%= if @config_changes == [] do %>
@ -245,7 +265,7 @@
<div class="mt-2 space-y-2">
<div
:for={change <- @config_changes}
class="flex items-center justify-between rounded-lg border border-base-300 p-3"
class="flex items-center justify-between rounded-lg border border-gray-200 dark:border-white/10 p-3"
>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
@ -268,10 +288,10 @@
<%!-- Right Column --%>
<div class="space-y-6">
<%!-- Device Health Grid --%>
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
<div class="flex items-center justify-between">
<h3 class="card-title text-base">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<.icon name="hero-server" class="h-5 w-5" /> Device Health
</h3>
<.button
@ -283,7 +303,7 @@
</.button>
</div>
<%= if @device == [] do %>
<div class="mt-2 rounded-lg border-2 border-dashed border-base-300 p-8 text-center">
<div class="mt-2 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 p-8 text-center">
<.icon
name="hero-server"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
@ -301,23 +321,49 @@
</div>
</div>
<% else %>
<div class="mt-2 grid gap-2 sm:grid-cols-2">
<.link
:for={eq <- @device}
navigate={~p"/devices/#{eq.id}"}
class="flex items-center gap-3 rounded-lg border border-base-300 p-3 hover:bg-base-200 transition-colors cursor-pointer"
>
<span class={["h-3 w-3 shrink-0 rounded-full", status_dot_class(eq.status)]} />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{eq.name}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{eq.ip_address}</p>
</div>
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">
{format_response_time(@response_times[eq.id])}
</span>
</.link>
<div class="mt-2 overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead>
<tr class="text-xs text-gray-500 dark:text-gray-400">
<th class="px-3 py-2 text-left font-medium">{t("Device")}</th>
<th class="px-3 py-2 text-left font-medium">{t("IP Address")}</th>
<th class="px-3 py-2 text-right font-medium">{t("Latency")}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5">
<tr
:for={eq <- @device}
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
phx-click={JS.navigate(~p"/devices/#{eq.id}")}
>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<span class={[
"h-2 w-2 shrink-0 rounded-full",
status_dot_class(eq.status)
]} />
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
{eq.name}
</span>
</div>
</td>
<td class="px-3 py-2 font-mono text-xs text-gray-500 dark:text-gray-400">
{eq.ip_address}
</td>
<td class={[
"px-3 py-2 text-right font-mono text-xs",
case @response_times[eq.id] do
nil -> "text-gray-400"
ms when ms < 50 -> "text-green-600 dark:text-green-400"
ms when ms < 200 -> "text-yellow-600 dark:text-yellow-400"
_ -> "text-red-600 dark:text-red-400"
end
]}>
{format_response_time(@response_times[eq.id])}
</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>
@ -325,15 +371,15 @@
<%!-- Insights Panel --%>
<%= if @insights != [] do %>
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<h3 class="card-title text-base">
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<.icon name="hero-light-bulb" class="h-5 w-5" /> Preseem Insights
</h3>
<div class="mt-2 space-y-2">
<div
:for={insight <- @insights}
class="flex items-start gap-3 rounded-lg border border-base-300 p-3"
class="flex items-start gap-3 rounded-lg border border-gray-200 dark:border-white/10 p-3"
>
<.icon
name={insight_urgency_icon(insight.urgency)}
@ -358,22 +404,30 @@
<%!-- Latency Chart --%>
<%= if @latency_chart_data do %>
<div class="mt-6">
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<h3 class="card-title text-base">
<.icon name="hero-chart-bar" class="h-5 w-5" /> Site Latency — Last 24 Hours
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<.icon name="hero-chart-bar" class="h-4 w-4" /> {t("Site Latency — Last 24 Hours")}
</h3>
<div class="mt-2">
<div
id="site-latency-chart"
phx-hook="SensorChart"
data-chart={@latency_chart_data}
data-unit="ms"
data-auto-scale="true"
style="height: 400px;"
>
<canvas></canvas>
</div>
<div class="p-4">
<div
id="site-latency-chart"
phx-hook="SensorChart"
data-chart={@latency_chart_data}
data-unit="ms"
data-auto-scale="true"
class="relative"
style="height: 400px;"
>
<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>
</div>

View file

@ -11,95 +11,108 @@
</p>
</div>
<header class="border-b border-gray-200 dark:border-white/5">
<nav class="flex overflow-x-auto py-4">
<ul
role="list"
class="flex min-w-full flex-none gap-x-6 px-4 text-sm/6 font-semibold text-gray-500 sm:px-6 lg:px-8 dark:text-gray-400"
<div class="border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex space-x-8 overflow-x-auto">
<.link
patch={~p"/users/settings?tab=personal"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
if(@active_tab == "personal",
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
else:
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
)
]}
>
<li>
<.link
patch={~p"/users/settings?tab=personal"}
class={
if @active_tab == "personal",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
{t("Personal")}
</.link>
</li>
<li>
<.link
patch={~p"/users/settings?tab=account"}
class={
if @active_tab == "account",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
{t("Account")}
</.link>
</li>
<li>
<.link
patch={~p"/users/settings?tab=sessions"}
class={
if @active_tab == "sessions",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
{t("Sessions")}
</.link>
</li>
<li>
<.link
patch={~p"/users/settings?tab=api"}
class={
if @active_tab == "api",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
{t("API")}
</.link>
</li>
<li>
<.link
patch={~p"/users/settings?tab=notifications"}
class={
if @active_tab == "notifications",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
{t("Notifications")}
</.link>
</li>
<li>
<.link
patch={~p"/users/settings?tab=security"}
class={
if @active_tab == "security",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
{t("Security")}
</.link>
</li>
<li>
<.link
navigate={~p"/users/my-data"}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
{t("My Data")}
</.link>
</li>
</ul>
<span class="flex items-center gap-1.5">
<.icon name="hero-user" class="h-4 w-4" /> {t("Personal")}
</span>
</.link>
<.link
patch={~p"/users/settings?tab=account"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
if(@active_tab == "account",
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
else:
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
)
]}
>
<span class="flex items-center gap-1.5">
<.icon name="hero-envelope" class="h-4 w-4" /> {t("Account")}
</span>
</.link>
<.link
patch={~p"/users/settings?tab=sessions"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
if(@active_tab == "sessions",
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
else:
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
)
]}
>
<span class="flex items-center gap-1.5">
<.icon name="hero-computer-desktop" class="h-4 w-4" /> {t("Sessions")}
</span>
</.link>
<.link
patch={~p"/users/settings?tab=api"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
if(@active_tab == "api",
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
else:
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
)
]}
>
<span class="flex items-center gap-1.5">
<.icon name="hero-code-bracket" class="h-4 w-4" /> {t("API")}
</span>
</.link>
<.link
patch={~p"/users/settings?tab=notifications"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
if(@active_tab == "notifications",
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
else:
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
)
]}
>
<span class="flex items-center gap-1.5">
<.icon name="hero-bell" class="h-4 w-4" /> {t("Notifications")}
</span>
</.link>
<.link
patch={~p"/users/settings?tab=security"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
if(@active_tab == "security",
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
else:
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
)
]}
>
<span class="flex items-center gap-1.5">
<.icon name="hero-shield-check" class="h-4 w-4" /> {t("Security")}
</span>
</.link>
<.link
navigate={~p"/users/my-data"}
class="whitespace-nowrap py-4 px-1 border-b-2 border-transparent text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
>
<span class="flex items-center gap-1.5">
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> {t("My Data")}
</span>
</.link>
</nav>
</header>
</div>
<div class="divide-y divide-gray-200 dark:divide-white/10">
<!-- Personal Information Section -->
@ -777,27 +790,38 @@
<%= for session <- @browser_sessions do %>
<% is_current = current_session?(session, get_current_token_id(assigns)) %>
<li class="flex items-center justify-between gap-x-6 py-5">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-x-3">
<p class="text-sm/6 font-semibold text-gray-900 dark:text-white">
{format_browser_info(session)}
</p>
<%= if is_current do %>
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 inset-ring-1 inset-ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:inset-ring-green-500/20">
{t("Current Session")}
</span>
<div class="flex items-start gap-x-3 min-w-0 flex-1">
<div class="flex-shrink-0 mt-1">
<%= if String.contains?(String.downcase(session.user_agent || ""), ["mobile", "android", "iphone", "ipad"]) do %>
<.icon name="hero-device-phone-mobile" class="h-6 w-6 text-gray-400" />
<% else %>
<.icon name="hero-computer-desktop" class="h-6 w-6 text-gray-400" />
<% end %>
</div>
<div class="mt-1 flex flex-col gap-y-1 text-xs/5 text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-x-2">
<.icon name="hero-map-pin" class="h-3 w-3" />
<span>{format_location(session)}</span>
<span class="text-gray-400 dark:text-gray-600">•</span>
<span class="font-mono">{session.ip_address}</span>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-x-3">
<p class="text-sm/6 font-semibold text-gray-900 dark:text-white">
{format_browser_info(session)}
</p>
<%= if is_current do %>
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 inset-ring-1 inset-ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:inset-ring-green-500/20">
{t("Current Session")}
</span>
<% end %>
</div>
<div class="flex items-center gap-x-2">
<.icon name="hero-clock" class="h-3 w-3" />
<span>Last active {format_relative_time(session.last_activity_at)}</span>
<div class="mt-1 flex flex-col gap-y-1 text-xs/5 text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-x-2">
<.icon name="hero-map-pin" class="h-3 w-3" />
<span>{format_location(session)}</span>
<span class="text-gray-400 dark:text-gray-600">•</span>
<span class="font-mono">{session.ip_address}</span>
</div>
<div class="flex items-center gap-x-2">
<.icon name="hero-clock" class="h-3 w-3" />
<span>
Last active {format_relative_time(session.last_activity_at)}
</span>
</div>
</div>
</div>
</div>
@ -1572,12 +1596,35 @@
>
{t("Scan QR Code")}
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
{t(
"Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.)."
)}
</p>
<div class="mt-4 text-left">
<div class="space-y-3">
<div class="flex items-start gap-3">
<span class="flex-shrink-0 flex h-6 w-6 items-center justify-center rounded-full bg-indigo-100 text-xs font-bold text-indigo-600 dark:bg-indigo-900 dark:text-indigo-400">
1
</span>
<p class="text-sm text-gray-600 dark:text-gray-300">
{t(
"Install an authenticator app (Google Authenticator, Authy, 1Password, etc.)"
)}
</p>
</div>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 flex h-6 w-6 items-center justify-center rounded-full bg-indigo-100 text-xs font-bold text-indigo-600 dark:bg-indigo-900 dark:text-indigo-400">
2
</span>
<p class="text-sm text-gray-600 dark:text-gray-300">
{t("Scan the QR code below with your authenticator app")}
</p>
</div>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 flex h-6 w-6 items-center justify-center rounded-full bg-indigo-100 text-xs font-bold text-indigo-600 dark:bg-indigo-900 dark:text-indigo-400">
3
</span>
<p class="text-sm text-gray-600 dark:text-gray-300">
{t("Enter the 6-digit code from your app to verify setup")}
</p>
</div>
</div>
</div>
<!-- QR Code -->