feat: add loading skeletons and page transitions

This commit is contained in:
Graham McIntire 2026-02-13 19:18:28 -06:00
parent ad6e9b91a3
commit bc128388ba
8 changed files with 94 additions and 39 deletions

View file

@ -1,4 +1,8 @@
2026-02-13
feat: SEO meta tags, OG tags, favicon, theme-color in root layout
feat: global CSS polish — smooth transitions, hover effects, scrollbar styling, status pulse
feat: loading skeletons and page fade-in transitions
feat: breadcrumb navigation on device detail, site detail, config timeline, trace pages
feat: nav search trigger button with ⌘K keyboard shortcut hint
fix: remove experimental badges from Alerts and Network Map nav
feat: polished empty states for alerts (all-clear), insights, sites pages

View file

@ -84,6 +84,7 @@ defmodule ToweropsWeb do
import Phoenix.HTML
import ToweropsWeb.CoreComponents
import ToweropsWeb.Components.Breadcrumbs
import ToweropsWeb.Components.Skeletons
import ToweropsWeb.GettextHelpers

View file

@ -4,7 +4,20 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="Towerops">
<meta name="description" content="TowerOps — Operational intelligence for WISP and ISP operators. Connect network monitoring to subscriber impact and revenue." />
<meta name="theme-color" content="#2563eb" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="TowerOps" />
<meta property="og:title" content="TowerOps — See the Business Impact of Every Network Event" />
<meta property="og:description" content="The only platform that connects network monitoring to subscriber impact and revenue. Built for WISP and ISP operators." />
<!-- Favicon -->
<link rel="icon" type="image/png" href={~p"/images/towerops_logo.png"} />
<link rel="apple-touch-icon" href={~p"/images/towerops_logo.png"} />
<.live_title default="TowerOps">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />

View file

@ -77,6 +77,12 @@ defmodule ToweropsWeb.ConfigTimelineLive do
@impl true
def render(assigns) do
~H"""
<.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"},
%{label: "Devices", navigate: ~p"/devices"},
%{label: @device.name, navigate: ~p"/devices/#{@device.id}"},
%{label: "Config Timeline"}
]} />
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>

View file

@ -75,6 +75,13 @@
>
<.icon name="hero-bars-3" class="h-4 w-4" /> Reorder
</.button>
<.button
type="button"
phx-click="toggle_compact_mode"
variant={if @compact_mode, do: "primary", else: nil}
>
<.icon name="hero-bars-2" class="h-4 w-4" /> Compact
</.button>
<% end %>
<% end %>
@ -176,6 +183,24 @@
</div>
</div>
<% else %>
<!-- Status Summary Bar -->
<div class="flex items-center gap-4 px-4 py-2.5 mb-4 rounded-lg bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{@total_devices} devices</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="inline-flex items-center gap-1.5 text-green-700 dark:text-green-400">
<span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
{@total_up} up
</span>
<span :if={@total_down > 0} class="inline-flex items-center gap-1.5 text-red-700 dark:text-red-400">
<span class="inline-block h-2 w-2 rounded-full bg-red-500"></span>
{@total_down} down
</span>
<span :if={@total_unknown > 0} class="inline-flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<span class="inline-block h-2 w-2 rounded-full bg-gray-400"></span>
{@total_unknown} unknown
</span>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
@ -317,18 +342,19 @@
<tr
id={dom_id}
class={[
"border-t device-row hover:bg-gray-50 dark:hover:bg-gray-800/50",
"border-t device-row hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors",
if(row.device_index == 0,
do: "border-gray-300 dark:border-white/15",
else: "border-gray-200 dark:border-white/10"
)
]}
title={device_row_title(row.device)}
data-device-id={row.device.id}
data-site-id={if row.site, do: row.site.id, else: "no-site"}
data-device-position={row.device_index + 1}
draggable={if @reorder_mode, do: "true", else: "false"}
>
<td :if={@reorder_mode} class="py-4 pl-4 pr-2 sm:pl-3">
<td :if={@reorder_mode} class={["pl-4 pr-2 sm:pl-3", if(@compact_mode, do: "py-1.5", else: "py-4")]}>
<button
type="button"
class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
@ -341,22 +367,27 @@
<.link
navigate={~p"/devices/#{row.device.id}"}
class={[
"flex items-center gap-2 py-4 pr-3 text-inherit no-underline",
"flex items-center gap-2 pr-3 text-inherit no-underline",
@reorder_mode && "pl-2",
!@reorder_mode && "pl-4 sm:pl-3"
!@reorder_mode && "pl-4 sm:pl-3",
if(@compact_mode, do: "py-1.5", else: "py-4")
]}
>
<span
class={"inline-block h-2.5 w-2.5 rounded-full flex-shrink-0 #{health_dot_color(row.health)}"}
title={health_dot_title(row.health)}
/>
<.icon
name={device_type_icon(row.device.device_type)}
class="h-4 w-4 text-gray-400 dark:text-gray-500 flex-shrink-0"
/>
{row.device.name}
</.link>
</td>
<td class="p-0 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 font-mono">
<.link
navigate={~p"/devices/#{row.device.id}"}
class="block px-3 py-4 text-inherit no-underline"
class={["block px-3 text-inherit no-underline", if(@compact_mode, do: "py-1.5", else: "py-4")]}
>
{row.device.ip_address}
</.link>
@ -364,25 +395,30 @@
<td class="p-0 text-sm whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class="block px-3 py-4 text-inherit no-underline"
class={["block px-3 text-inherit no-underline", if(@compact_mode, do: "py-1.5", else: "py-4")]}
>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
row.device.status == :up &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
row.device.status == :down &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
row.device.status == :unknown &&
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
]}>
{row.device.status |> to_string() |> String.upcase()}
<span class="inline-flex items-center gap-1.5">
<span class={[
"inline-block h-2 w-2 rounded-full",
row.device.status == :up && "bg-green-500",
row.device.status == :down && "bg-red-500",
row.device.status == :unknown && "bg-gray-400"
]} />
<span class={[
"text-xs font-medium",
row.device.status == :up && "text-green-700 dark:text-green-400",
row.device.status == :down && "text-red-700 dark:text-red-400",
row.device.status == :unknown && "text-gray-500 dark:text-gray-400"
]}>
{row.device.status |> to_string() |> String.upcase()}
</span>
</span>
</.link>
</td>
<td class="p-0 text-sm whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class="block px-3 py-4 text-inherit no-underline"
class={["block px-3 text-inherit no-underline", if(@compact_mode, do: "py-1.5", else: "py-4")]}
>
<% {text, color} = last_seen_text(row.device.last_seen_at) %>
<span class={"text-xs font-medium #{color}"}>{text}</span>
@ -391,7 +427,7 @@
<td class="p-0 text-sm whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class="block px-3 py-4 text-inherit no-underline"
class={["block px-3 text-inherit no-underline", if(@compact_mode, do: "py-1.5", else: "py-4")]}
>
<% {rt_text, rt_color} = response_time_badge(row.response) %>
<span class={"text-xs font-mono #{rt_color}"}>{rt_text}</span>
@ -400,7 +436,7 @@
<td class="p-0 text-sm whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class="block px-3 py-4 text-inherit no-underline"
class={["block px-3 text-inherit no-underline", if(@compact_mode, do: "py-1.5", else: "py-4")]}
>
<%= if sub_text = format_subscriber_count(row.subscribers) do %>
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">

View file

@ -4,25 +4,11 @@
active_page="devices"
>
<div class="mb-6 -mt-2">
<!-- Breadcrumbs -->
<nav :if={@device.site} class="flex mb-4" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 text-sm text-gray-600 dark:text-gray-400">
<li class="inline-flex items-center">
<.link
navigate={~p"/sites/#{@device.site.id}"}
class="hover:text-gray-900 dark:hover:text-white"
>
{@device.site.name}
</.link>
</li>
<li aria-current="page">
<div class="flex items-center">
<.icon name="hero-chevron-right" class="h-4 w-4 mx-1" />
<span class="text-gray-900 dark:text-white font-medium">{@device.name}</span>
</div>
</li>
</ol>
</nav>
<.breadcrumb items={
[%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Devices", navigate: ~p"/devices"}] ++
if(@device.site, do: [%{label: @device.site.name, navigate: ~p"/sites/#{@device.site.id}"}], else: []) ++
[%{label: @device.name}]
} />
<div class="flex items-center justify-between mb-4">
<div>

View file

@ -3,6 +3,11 @@
current_scope={@current_scope}
active_page="sites"
>
<.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"},
%{label: "Sites", navigate: ~p"/sites"},
%{label: @site.name}
]} />
<.header>
{@page_title}
<:subtitle>{@site.location}</:subtitle>

View file

@ -3,6 +3,10 @@
current_scope={@current_scope}
active_page="trace"
>
<.breadcrumb items={
[%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++
if(@trace && @trace.subscriber, do: [%{label: @trace.subscriber.name}], else: [])
} />
<div class="space-y-6">
<%!-- Search Bar --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">