feat(ui): replace skeleton loaders with honest loading states
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { RouterProvider, createRouter } from '@tanstack/react-router'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
import { useAuth } from './lib/auth'
|
import { useAuth } from './lib/auth'
|
||||||
import { Skeleton } from './components/ui/skeleton'
|
import { LoadingText } from './components/ui/skeleton'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
@@ -24,17 +24,13 @@ function AppInner() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Only show skeleton during initial auth check -- NOT on subsequent isLoading changes.
|
// Only show loading text during initial auth check -- NOT on subsequent isLoading changes.
|
||||||
// Reacting to isLoading here would unmount the entire router tree (including LoginPage)
|
// Reacting to isLoading here would unmount the entire router tree (including LoginPage)
|
||||||
// every time an auth action sets isLoading, destroying all component local state.
|
// every time an auth action sets isLoading, destroying all component local state.
|
||||||
if (!hasChecked) {
|
if (!hasChecked) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||||
<div className="space-y-4 w-64">
|
<LoadingText />
|
||||||
<Skeleton className="h-8 w-48 mx-auto" />
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function CertificatesPage() {
|
|||||||
Certificate Authority
|
Certificate Authority
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<TableSkeleton rows={3} />
|
<TableSkeleton />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export function DeviceCertTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <TableSkeleton rows={4} />
|
return <TableSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -64,14 +64,8 @@ export function EntryTable({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="py-8 text-center">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||||
<div className="h-5 w-48 bg-elevated/50 rounded animate-pulse" />
|
|
||||||
<div className="h-8 w-24 bg-elevated/50 rounded animate-pulse" />
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} className="h-10 bg-panel rounded animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ function DeviceSelector({
|
|||||||
onSelectionChange(new Set())
|
onSelectionChange(new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) return <TableSkeleton rows={5} />
|
if (isLoading) return <TableSkeleton />
|
||||||
|
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -126,8 +126,8 @@ export function ConfigDiffViewer({
|
|||||||
|
|
||||||
{/* Diff content */}
|
{/* Diff content */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-8 text-center text-sm text-text-muted animate-pulse">
|
<div className="py-8 text-center">
|
||||||
Loading diff...
|
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
) : !diffFile ? (
|
) : !diffFile ? (
|
||||||
<div className="p-8 text-center text-sm text-text-muted">
|
<div className="p-8 text-center text-sm text-text-muted">
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function ConfigHistorySection({ tenantId, deviceId, deviceName }: ConfigH
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableSkeleton rows={3} />
|
<TableSkeleton />
|
||||||
) : !changes || changes.length === 0 ? (
|
) : !changes || changes.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-6">
|
<div className="flex items-center justify-center py-6">
|
||||||
<span className="text-xs text-text-muted">No configuration changes recorded yet.</span>
|
<span className="text-xs text-text-muted">No configuration changes recorded yet.</span>
|
||||||
|
|||||||
@@ -158,13 +158,8 @@ export function ConfigTab({
|
|||||||
{/* Timeline panel */}
|
{/* Timeline panel */}
|
||||||
<div className="w-72 flex-shrink-0">
|
<div className="w-72 flex-shrink-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="py-8 text-center">
|
||||||
{[0, 1, 2].map((i) => (
|
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-14 rounded-lg border border-border bg-panel animate-pulse"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : !backups || backups.length === 0 ? (
|
) : !backups || backups.length === 0 ? (
|
||||||
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
|
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
|
||||||
|
|||||||
@@ -47,10 +47,8 @@ export function DiffViewer({ tenantId, deviceId, snapshotId, onClose }: DiffView
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="py-8 text-center">
|
||||||
{[75, 90, 65, 85, 70, 80].map((w, i) => (
|
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||||
<div key={i} className="h-4 bg-elevated rounded animate-pulse" style={{ width: `${w}%` }} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : isError || !diff ? (
|
) : isError || !diff ? (
|
||||||
<div className="flex items-center justify-center py-6">
|
<div className="flex items-center justify-center py-6">
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { LoadingText } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -1340,16 +1340,10 @@ function CellEmpty() {
|
|||||||
|
|
||||||
function LoadingRows({ cols }: { cols: number }) {
|
function LoadingRows({ cols }: { cols: number }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<tr>
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
<td colSpan={cols} className="py-8 text-center">
|
||||||
<tr key={i} className="border-b border-border/50">
|
<LoadingText />
|
||||||
{Array.from({ length: cols }).map((_, j) => (
|
</td>
|
||||||
<td key={j} className="px-3 py-2">
|
</tr>
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { LoadingText } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -228,23 +228,13 @@ export function InterfacesPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Loading skeleton
|
// Loading state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
function TableLoading() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-panel">
|
<div className="py-8 text-center">
|
||||||
<div className="p-3 border-b border-border">
|
<LoadingText />
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: rows }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-4 p-3 border-b border-border last:border-0">
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-4 w-16" />
|
|
||||||
<Skeleton className="h-4 w-20" />
|
|
||||||
<Skeleton className="h-4 w-12" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -260,7 +250,7 @@ function InterfacesTable({
|
|||||||
entries: Record<string, string>[]
|
entries: Record<string, string>[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}) {
|
}) {
|
||||||
if (isLoading) return <TableSkeleton />
|
if (isLoading) return <TableLoading />
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -362,7 +352,7 @@ function IpAddressesTab({ entries, isLoading, interfaceNames, addChange }: IpAdd
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) return <TableSkeleton />
|
if (isLoading) return <TableLoading />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -621,7 +611,7 @@ function VlansTab({ entries, isLoading, interfaceNames, addChange }: VlansTabPro
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) return <TableSkeleton />
|
if (isLoading) return <TableLoading />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -926,7 +916,7 @@ function BridgesTab({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) return <TableSkeleton />
|
if (isLoading) return <TableLoading />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -52,10 +52,8 @@ export function RestorePreview({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="py-8 text-center">
|
||||||
<div className="h-12 rounded-lg bg-elevated animate-pulse" />
|
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||||
<div className="h-32 rounded-lg bg-elevated animate-pulse" />
|
|
||||||
<div className="h-16 rounded-lg bg-elevated animate-pulse" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Zap, Network } from 'lucide-react'
|
import { Zap, Network } from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { LoadingText } from '@/components/ui/skeleton'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { useConfigBrowse } from '@/hooks/useConfigPanel'
|
import { useConfigBrowse } from '@/hooks/useConfigPanel'
|
||||||
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
|
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
|
||||||
@@ -134,12 +134,8 @@ export function SwitchPortManager({ tenantId, deviceId, active }: ConfigPanelPro
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-panel p-4">
|
<div className="py-8 text-center">
|
||||||
<div className="flex flex-wrap gap-2">
|
<LoadingText />
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-20 w-16 rounded-md" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { alertsApi } from '@/lib/alertsApi'
|
|||||||
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
||||||
import { LayoutDashboard } from 'lucide-react'
|
import { LayoutDashboard } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { LoadingText } from '@/components/ui/skeleton'
|
||||||
import { EmptyState } from '@/components/ui/empty-state'
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
|
|
||||||
// ─── Dashboard Widgets ───────────────────────────────────────────────────────
|
// ─── Dashboard Widgets ───────────────────────────────────────────────────────
|
||||||
@@ -30,39 +30,12 @@ const REFRESH_OPTIONS: { label: string; value: RefreshInterval }[] = [
|
|||||||
{ label: 'Off', value: false },
|
{ label: 'Off', value: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ─── Dashboard Skeleton ──────────────────────────────────────────────────────
|
// ─── Dashboard Loading ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DashboardSkeleton() {
|
function DashboardLoading() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="py-8 text-center">
|
||||||
{/* KPI cards skeleton */}
|
<LoadingText />
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} className="rounded-lg border border-border p-4">
|
|
||||||
<Skeleton className="h-3 w-24 mb-2" />
|
|
||||||
<Skeleton className="h-8 w-16" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* Widget grid skeleton */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div className="lg:col-span-2 rounded-lg border border-border p-4 space-y-3">
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
<Skeleton className="h-48 w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-32 w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="lg:col-span-2 rounded-lg border border-border p-4 space-y-3">
|
|
||||||
<Skeleton className="h-4 w-40" />
|
|
||||||
<Skeleton className="h-48 w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
|
||||||
<Skeleton className="h-4 w-28" />
|
|
||||||
<Skeleton className="h-32 w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -236,7 +209,7 @@ export function FleetDashboard() {
|
|||||||
|
|
||||||
{/* ── Dashboard Content ───────────────────────────────────────────── */}
|
{/* ── Dashboard Content ───────────────────────────────────────────── */}
|
||||||
{fleetLoading ? (
|
{fleetLoading ? (
|
||||||
<DashboardSkeleton />
|
<DashboardLoading />
|
||||||
) : totalDevices === 0 ? (
|
) : totalDevices === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={LayoutDashboard}
|
icon={LayoutDashboard}
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ export function FleetTable({
|
|||||||
isFetching && !isLoading && 'opacity-70',
|
isFetching && !isLoading && 'opacity-70',
|
||||||
)}>
|
)}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableSkeleton rows={3} />
|
<TableSkeleton />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Monitor}
|
icon={Monitor}
|
||||||
|
|||||||
@@ -99,13 +99,8 @@ export function MaintenanceList({ tenantId }: MaintenanceListProps) {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="py-8 text-center">
|
||||||
{[1, 2, 3].map((i) => (
|
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-20 rounded-lg border border-border bg-panel animate-pulse"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { MapPin } from 'lucide-react'
|
import { MapPin } from 'lucide-react'
|
||||||
import { metricsApi, tenantsApi } from '@/lib/api'
|
import { metricsApi, tenantsApi } from '@/lib/api'
|
||||||
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { LoadingText } from '@/components/ui/skeleton'
|
||||||
import { FleetMap } from './FleetMap'
|
import { FleetMap } from './FleetMap'
|
||||||
|
|
||||||
export function MapPage() {
|
export function MapPage() {
|
||||||
@@ -55,7 +55,11 @@ export function MapPage() {
|
|||||||
}, [superAdmin, selectedTenant, user])
|
}, [superAdmin, selectedTenant, user])
|
||||||
|
|
||||||
if (devicesLoading) {
|
if (devicesLoading) {
|
||||||
return <Skeleton className="h-[calc(100vh-8rem)] w-full rounded-lg" />
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[calc(100vh-8rem)]">
|
||||||
|
<LoadingText />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (devicesError) {
|
if (devicesError) {
|
||||||
|
|||||||
@@ -41,10 +41,8 @@ export function HealthTab({ tenantId, deviceId, active = true }: HealthTabProps)
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="py-8 text-center">
|
||||||
{[0, 1, 2, 3].map((i) => (
|
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||||
<div key={i} className="rounded-lg border border-border bg-panel p-4 h-44 animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : !data || data.length === 0 ? (
|
) : !data || data.length === 0 ? (
|
||||||
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
|
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
|
||||||
|
|||||||
@@ -87,10 +87,8 @@ export function InterfacesTab({ tenantId, deviceId, active = true }: InterfacesT
|
|||||||
|
|
||||||
{/* Charts */}
|
{/* Charts */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-4">
|
<div className="py-8 text-center">
|
||||||
{[0, 1, 2].map((i) => (
|
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||||
<div key={i} className="rounded-lg border border-border bg-panel p-4 h-56 animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : !trafficData || trafficData.length === 0 ? (
|
) : !trafficData || trafficData.length === 0 ? (
|
||||||
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
|
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
|
||||||
|
|||||||
@@ -159,10 +159,8 @@ export function WirelessTab({ tenantId, deviceId, active = true }: WirelessTabPr
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-4">
|
<div className="py-8 text-center">
|
||||||
{[0, 1].map((i) => (
|
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||||
<div key={i} className="rounded-lg border border-border bg-panel p-4 h-48 animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : hasNoWireless ? (
|
) : hasNoWireless ? (
|
||||||
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
|
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function ClientsTab({ tenantId, deviceId, active }: ClientsTabProps) {
|
|||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <TableSkeleton rows={8} />
|
return <TableSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state
|
// Error state
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { metricsApi, type InterfaceMetricPoint } from '@/lib/api'
|
import { metricsApi, type InterfaceMetricPoint } from '@/lib/api'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { LoadingText } from '@/components/ui/skeleton'
|
||||||
|
|
||||||
interface InterfaceGaugesProps {
|
interface InterfaceGaugesProps {
|
||||||
tenantId: string
|
tenantId: string
|
||||||
@@ -101,14 +101,8 @@ export function InterfaceGauges({ tenantId, deviceId, active }: InterfaceGaugesP
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="py-8 text-center">
|
||||||
{[0, 1, 2].map((i) => (
|
<LoadingText />
|
||||||
<div key={i} className="rounded-lg border border-border bg-panel p-3">
|
|
||||||
<Skeleton className="h-4 w-24 mb-2" />
|
|
||||||
<Skeleton className="h-3 w-full mb-1" />
|
|
||||||
<Skeleton className="h-3 w-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Search, RefreshCw } from 'lucide-react'
|
import { Search, RefreshCw } from 'lucide-react'
|
||||||
import { networkApi, type LogEntry } from '@/lib/networkApi'
|
import { networkApi, type LogEntry } from '@/lib/networkApi'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { LoadingText } from '@/components/ui/skeleton'
|
||||||
|
|
||||||
interface LogsTabProps {
|
interface LogsTabProps {
|
||||||
tenantId: string
|
tenantId: string
|
||||||
@@ -62,16 +62,10 @@ function TopicBadge({ topics }: { topics: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableSkeleton() {
|
function TableLoading() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="py-8 text-center">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
<LoadingText />
|
||||||
<div key={i} className="flex gap-3 py-2 px-3">
|
|
||||||
<Skeleton className="h-4 w-32 shrink-0" />
|
|
||||||
<Skeleton className="h-4 w-20 shrink-0" />
|
|
||||||
<Skeleton className="h-4 flex-1" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -189,7 +183,7 @@ export function LogsTab({ tenantId, deviceId, active }: LogsTabProps) {
|
|||||||
{/* Log table */}
|
{/* Log table */}
|
||||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableSkeleton />
|
<TableLoading />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="p-6 text-center text-sm text-error">
|
<div className="p-6 text-center text-sm text-error">
|
||||||
Failed to fetch device logs. The device may be offline or unreachable.
|
Failed to fetch device logs. The device may be offline or unreachable.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Shield, Lock, Globe } from 'lucide-react'
|
import { Shield, Lock, Globe } from 'lucide-react'
|
||||||
import { networkApi, type VpnTunnel } from '@/lib/networkApi'
|
import { networkApi, type VpnTunnel } from '@/lib/networkApi'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { LoadingText } from '@/components/ui/skeleton'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
interface VpnTabProps {
|
interface VpnTabProps {
|
||||||
@@ -102,10 +102,8 @@ export function VpnTab({ tenantId, deviceId, active }: VpnTabProps) {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 py-8 text-center">
|
||||||
<Skeleton className="h-10 w-full" />
|
<LoadingText />
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -611,7 +611,7 @@ function DeviceSelectionStep({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (devicesLoading) {
|
if (devicesLoading) {
|
||||||
return <TableSkeleton rows={5} />
|
return <TableSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function SimpleStatusBanner({ items, isLoading }: SimpleStatusBannerProps
|
|||||||
<div key={i} className="flex flex-col">
|
<div key={i} className="flex flex-col">
|
||||||
<span className="text-xs text-text-muted">{item.label}</span>
|
<span className="text-xs text-text-muted">{item.label}</span>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="h-5 w-24 mt-0.5 rounded bg-elevated animate-pulse" />
|
<span className="text-[9px] text-text-muted mt-0.5">Loading…</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-medium text-text-primary">
|
<span className="text-sm font-medium text-text-primary">
|
||||||
{item.value || '\u2014'}
|
{item.value || '\u2014'}
|
||||||
|
|||||||
@@ -57,15 +57,8 @@ export function SiteHealthGrid({ tenantId, siteId }: SiteHealthGridProps) {
|
|||||||
|
|
||||||
if (devicesLoading) {
|
if (devicesLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
|
<div className="py-8 text-center">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||||
<div key={i} className="rounded-lg border border-border bg-panel p-4 space-y-3 animate-pulse">
|
|
||||||
<div className="h-4 w-24 bg-elevated rounded" />
|
|
||||||
<div className="h-1.5 w-full bg-elevated rounded-full" />
|
|
||||||
<div className="h-1.5 w-full bg-elevated rounded-full" />
|
|
||||||
<div className="h-3 w-16 bg-elevated rounded" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function SiteSectorView({ tenantId, siteId }: SiteSectorViewProps) {
|
|||||||
}, [linksData])
|
}, [linksData])
|
||||||
|
|
||||||
if (sectorsLoading || devicesLoading) {
|
if (sectorsLoading || devicesLoading) {
|
||||||
return <TableSkeleton rows={6} />
|
return <TableSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectors = sectorData?.items ?? []
|
const sectors = sectorData?.items ?? []
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function SiteTable({ tenantId, search, onCreateClick, onEditClick }: Site
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <TableSkeleton rows={4} />
|
return <TableSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.sites.length === 0) {
|
if (!data || data.sites.length === 0) {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function TenantList() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-3 py-4">
|
<td colSpan={5} className="px-3 py-4">
|
||||||
<TableSkeleton rows={5} />
|
<TableSkeleton />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : tenants?.length === 0 ? (
|
) : tenants?.length === 0 ? (
|
||||||
|
|||||||
@@ -1,84 +1,13 @@
|
|||||||
import { Skeleton } from './skeleton'
|
import { LoadingText } from './skeleton'
|
||||||
|
|
||||||
/** Table-style skeleton with header + rows */
|
export function TableSkeleton() {
|
||||||
export function TableSkeleton({ rows = 8 }: { rows?: number }) {
|
return <div className="py-8 text-center"><LoadingText /></div>
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
<Skeleton className="h-8 w-32 ml-auto" />
|
|
||||||
</div>
|
|
||||||
{/* Filter bar */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Skeleton className="h-9 w-64" />
|
|
||||||
<Skeleton className="h-9 w-32" />
|
|
||||||
</div>
|
|
||||||
{/* Table header */}
|
|
||||||
<div className="flex gap-4 py-2">
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-4 w-20" />
|
|
||||||
<Skeleton className="h-4 w-28" />
|
|
||||||
<Skeleton className="h-4 w-16 ml-auto" />
|
|
||||||
</div>
|
|
||||||
{/* Rows */}
|
|
||||||
{Array.from({ length: rows }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-4 py-3">
|
|
||||||
<Skeleton className="h-4 w-40" />
|
|
||||||
<Skeleton className="h-4 w-28" />
|
|
||||||
<Skeleton className="h-4 w-16" />
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-8 w-20 ml-auto" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Card grid skeleton */
|
export function CardGridSkeleton() {
|
||||||
export function CardGridSkeleton({ cards = 6 }: { cards?: number }) {
|
return <div className="py-8 text-center"><LoadingText /></div>
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{Array.from({ length: cards }).map((_, i) => (
|
|
||||||
<div key={i} className="rounded-lg border border-border p-4 space-y-3">
|
|
||||||
<Skeleton className="h-5 w-3/4" />
|
|
||||||
<Skeleton className="h-4 w-1/2" />
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detail page skeleton (header + tabs + content) */
|
|
||||||
export function DetailPageSkeleton() {
|
export function DetailPageSkeleton() {
|
||||||
return (
|
return <div className="py-8 text-center"><LoadingText /></div>
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton className="h-8 w-8 rounded-full" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Skeleton className="h-6 w-48" />
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-9 w-24 ml-auto" />
|
|
||||||
</div>
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-4 border-b border-border pb-2">
|
|
||||||
<Skeleton className="h-5 w-20" />
|
|
||||||
<Skeleton className="h-5 w-20" />
|
|
||||||
<Skeleton className="h-5 w-20" />
|
|
||||||
<Skeleton className="h-5 w-20" />
|
|
||||||
</div>
|
|
||||||
{/* Content area */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Skeleton className="h-48 w-full" />
|
|
||||||
<Skeleton className="h-48 w-full" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type SkeletonProps = React.HTMLAttributes<HTMLDivElement>
|
// Compatibility shim — returns null. Remove once all imports are cleaned up.
|
||||||
|
export function Skeleton({ className: _className }: { className?: string }) {
|
||||||
export function Skeleton({ className, ...props }: SkeletonProps) {
|
return null
|
||||||
return (
|
}
|
||||||
<div
|
|
||||||
className={cn(
|
// Use this for panels where loading delay is noticeable
|
||||||
'animate-pulse rounded-md bg-elevated relative overflow-hidden',
|
export function LoadingText({ text = 'Loading\u2026' }: { text?: string }) {
|
||||||
className,
|
return <span className="text-[9px] text-text-muted">{text}</span>
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function UserList({ tenantId }: Props) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-3 py-4">
|
<td colSpan={6} className="px-3 py-4">
|
||||||
<TableSkeleton rows={5} />
|
<TableSkeleton />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : users?.length === 0 ? (
|
) : users?.length === 0 ? (
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export function VpnPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<h1 className="text-2xl font-bold text-text-primary">VPN</h1>
|
<h1 className="text-2xl font-bold text-text-primary">VPN</h1>
|
||||||
<TableSkeleton rows={3} />
|
<TableSkeleton />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ export function VpnPage() {
|
|||||||
|
|
||||||
{/* Peer list */}
|
{/* Peer list */}
|
||||||
{peersLoading ? (
|
{peersLoading ? (
|
||||||
<TableSkeleton rows={3} />
|
<TableSkeleton />
|
||||||
) : peers.length === 0 ? (
|
) : peers.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-accent/30 bg-accent/5 p-8 text-center space-y-3">
|
<div className="rounded-lg border border-dashed border-accent/30 bg-accent/5 p-8 text-center space-y-3">
|
||||||
<ShieldCheck className="h-10 w-10 text-accent mx-auto" />
|
<ShieldCheck className="h-10 w-10 text-accent mx-auto" />
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function RFStatsCard({ tenantId, deviceId, active }: RFStatsCardProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <TableSkeleton rows={2} />
|
return <TableSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.items.length === 0) {
|
if (!data || data.items.length === 0) {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function WirelessLinksTable({ tenantId, siteId }: WirelessLinksTableProps
|
|||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <TableSkeleton rows={8} />
|
return <TableSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.items.length === 0) {
|
if (!data || data.items.length === 0) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function WirelessStationTable({ tenantId, deviceId, active }: WirelessSta
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <TableSkeleton rows={5} />
|
return <TableSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.items.length === 0) {
|
if (!data || data.items.length === 0) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { EventStreamProvider } from '@/contexts/EventStreamContext'
|
|||||||
import type { FleetDevice } from '@/lib/api'
|
import type { FleetDevice } from '@/lib/api'
|
||||||
import { AppLayout } from '@/components/layout/AppLayout'
|
import { AppLayout } from '@/components/layout/AppLayout'
|
||||||
import { PageTransition } from '@/components/layout/PageTransition'
|
import { PageTransition } from '@/components/layout/PageTransition'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { LoadingText } from '@/components/ui/skeleton'
|
||||||
import { ErrorBoundary } from '@/components/ui/error-boundary'
|
import { ErrorBoundary } from '@/components/ui/error-boundary'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated')({
|
export const Route = createFileRoute('/_authenticated')({
|
||||||
@@ -164,11 +164,7 @@ function AuthenticatedLayout() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||||
<div className="space-y-4 w-64">
|
<LoadingText />
|
||||||
<Skeleton className="h-8 w-48 mx-auto" />
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -800,7 +800,7 @@ function DeviceAlertsSection({
|
|||||||
const resolvedAlerts = alerts.filter((a) => a.status === 'resolved').slice(0, 5)
|
const resolvedAlerts = alerts.filter((a) => a.status === 'resolved').slice(0, 5)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <TableSkeleton rows={3} />
|
return <TableSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function TenantDetailPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <CardGridSkeleton cards={3} />
|
return <CardGridSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
|
|||||||
@@ -26,9 +26,8 @@ function SiteDetailPage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="animate-pulse space-y-4">
|
<div className="py-8 text-center">
|
||||||
<div className="h-6 w-48 bg-elevated rounded" />
|
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||||
<div className="h-4 w-64 bg-elevated rounded" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user