new ui + search clients
build-and-push / build (push) Failing after 54s

This commit is contained in:
2026-08-01 13:31:46 +05:00
parent 66073c7891
commit 2957347583
3 changed files with 233 additions and 71 deletions
+149 -61
View File
@@ -1,7 +1,9 @@
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import {
KeyRound,
LogOut,
Menu,
Moon,
Router,
ScrollText,
@@ -9,6 +11,7 @@ import {
UserCog,
Users as UsersIcon,
Wifi,
X,
} from 'lucide-react'
import { useAuth } from '@/auth/auth'
import { useTheme } from '@/lib/theme'
@@ -39,54 +42,145 @@ function ThemeToggle() {
)
}
function Nav({ username, isAdmin }: { username: string; isAdmin: boolean }) {
type NavItem = { to: string; label: string; icon: typeof Wifi }
const linkClass = ({ isActive }: { isActive: boolean }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent',
)
function SidebarContent({
username,
isAdmin,
onNavigate,
}: {
username: string
isAdmin: boolean
onNavigate?: () => void
}) {
const { logout } = useAuth()
const linkClass = ({ isActive }: { isActive: boolean }) =>
cn(
'inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
isActive ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent',
)
const items: NavItem[] = [
{ to: '/clients', label: 'Clients', icon: UsersIcon },
{ to: '/devices', label: 'Devices', icon: Router },
{ to: '/vlans', label: 'VLANs', icon: Wifi },
]
const adminItems: NavItem[] = [
{ to: '/users', label: 'Users', icon: UserCog },
{ to: '/apikeys', label: 'API Keys', icon: KeyRound },
{ to: '/logs', label: 'Activity Log', icon: ScrollText },
]
const renderItem = ({ to, label, icon: Icon }: NavItem) => (
<NavLink key={to} to={to} className={linkClass} onClick={onNavigate}>
<Icon className="h-4 w-4 shrink-0" /> {label}
</NavLink>
)
return (
<header className="border-b bg-background">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-1 px-4">
<div className="mr-4 flex items-center gap-2 font-semibold">
<div className="flex h-full flex-col">
<div className="flex h-14 items-center gap-2 px-4 font-semibold">
<Wifi className="h-5 w-5 text-primary" />
RADIUS Admin
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 py-2">
{items.map(renderItem)}
{isAdmin && (
<>
<div className="mt-4 px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Admin
</div>
{adminItems.map(renderItem)}
</>
)}
</nav>
<div className="border-t p-3">
<NavLink to="/account" className={linkClass} onClick={onNavigate} title="Change password">
<UserCog className="h-4 w-4 shrink-0" />
<span className="truncate">{username}</span>
</NavLink>
<Button
variant="ghost"
className="mt-1 w-full justify-start gap-3 px-3 text-muted-foreground"
onClick={logout}
>
<LogOut className="h-4 w-4 shrink-0" /> Sign out
</Button>
</div>
</div>
)
}
function Shell({
username,
isAdmin,
children,
}: {
username: string
isAdmin: boolean
children: React.ReactNode
}) {
const [open, setOpen] = useState(false)
const location = useLocation()
// Close the mobile drawer whenever the route changes.
useEffect(() => {
setOpen(false)
}, [location.pathname])
return (
<div className="min-h-screen bg-muted/20">
{/* Desktop sidebar */}
<aside className="fixed inset-y-0 left-0 z-30 hidden w-60 border-r bg-background md:block">
<SidebarContent username={username} isAdmin={isAdmin} />
</aside>
{/* Mobile drawer */}
{open && (
<div className="fixed inset-0 z-40 md:hidden">
<div
className="absolute inset-0 bg-black/50"
onClick={() => setOpen(false)}
aria-hidden="true"
/>
<aside className="absolute inset-y-0 left-0 w-64 border-r bg-background shadow-lg">
<SidebarContent
username={username}
isAdmin={isAdmin}
onNavigate={() => setOpen(false)}
/>
</aside>
</div>
)}
{/* Mobile top bar */}
<header className="sticky top-0 z-20 flex h-14 items-center gap-2 border-b bg-background px-4 md:hidden">
<Button
variant="ghost"
size="icon"
onClick={() => setOpen((v) => !v)}
aria-label={open ? 'Close menu' : 'Open menu'}
>
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
<div className="flex items-center gap-2 font-semibold">
<Wifi className="h-5 w-5 text-primary" />
RADIUS Admin
</div>
<NavLink to="/clients" className={linkClass}>
<UsersIcon className="h-4 w-4" /> Clients
</NavLink>
<NavLink to="/devices" className={linkClass}>
<Router className="h-4 w-4" /> Devices
</NavLink>
<NavLink to="/vlans" className={linkClass}>
<Wifi className="h-4 w-4" /> VLANs
</NavLink>
{isAdmin && (
<>
<NavLink to="/users" className={linkClass}>
<UserCog className="h-4 w-4" /> Users
</NavLink>
<NavLink to="/apikeys" className={linkClass}>
<KeyRound className="h-4 w-4" /> API Keys
</NavLink>
<NavLink to="/logs" className={linkClass}>
<ScrollText className="h-4 w-4" /> Activity Log
</NavLink>
</>
)}
<div className="ml-auto flex items-center gap-1">
<NavLink to="/account" className={linkClass} title="Change password">
{username}
</NavLink>
<div className="ml-auto">
<ThemeToggle />
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={logout}>
<LogOut className="h-4 w-4" /> Sign out
</Button>
</div>
</header>
<div className="md:pl-60">
{/* Desktop top-right theme toggle */}
<div className="hidden items-center justify-end px-6 pt-4 md:flex">
<ThemeToggle />
</div>
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
</div>
</header>
</div>
)
}
@@ -100,26 +194,20 @@ export default function App() {
const admin = user.is_admin
return (
<div className="min-h-screen bg-muted/20">
<Nav username={user.username} isAdmin={admin} />
<main className="mx-auto max-w-6xl px-4 py-6">
<Routes>
<Route path="/clients" element={<Clients />} />
<Route path="/devices" element={<Devices />} />
<Route path="/vlans" element={<Vlans />} />
<Route path="/account" element={<ChangePassword />} />
<Route
path="/users"
element={admin ? <Users /> : <Navigate to="/clients" replace />}
/>
<Route
path="/apikeys"
element={admin ? <ApiKeys /> : <Navigate to="/clients" replace />}
/>
<Route path="/logs" element={admin ? <Logs /> : <Navigate to="/clients" replace />} />
<Route path="*" element={<Navigate to="/clients" replace />} />
</Routes>
</main>
</div>
<Shell username={user.username} isAdmin={admin}>
<Routes>
<Route path="/clients" element={<Clients />} />
<Route path="/devices" element={<Devices />} />
<Route path="/vlans" element={<Vlans />} />
<Route path="/account" element={<ChangePassword />} />
<Route path="/users" element={admin ? <Users /> : <Navigate to="/clients" replace />} />
<Route
path="/apikeys"
element={admin ? <ApiKeys /> : <Navigate to="/clients" replace />}
/>
<Route path="/logs" element={admin ? <Logs /> : <Navigate to="/clients" replace />} />
<Route path="*" element={<Navigate to="/clients" replace />} />
</Routes>
</Shell>
)
}
+11 -2
View File
@@ -111,6 +111,11 @@ export interface ClientEdit {
alias?: string
}
export interface ClientFilters {
search?: string
status?: ClientStatus
}
export interface ClientImportRow {
mac_address?: string
group?: string
@@ -260,8 +265,12 @@ export const api = {
},
clients: {
list: (limit = 50, offset = 0) =>
request<Page<Client>>(`/client/?limit=${limit}&offset=${offset}`),
list: (limit = 50, offset = 0, filters: ClientFilters = {}) => {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
if (filters.search?.trim()) params.set('search', filters.search.trim())
if (filters.status) params.set('status', filters.status)
return request<Page<Client>>(`/client/?${params.toString()}`)
},
get: (mac: string) => request<Client>(`/client/${encodeURIComponent(mac)}`),
add: (body: ClientCreate) =>
request<Client>('/client/add', { method: 'POST', body: JSON.stringify(body) }),
+73 -8
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
import { Loader2, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import { api, ApiError, type Client, type ClientStatus, type Vlan } from '@/lib/api'
import { normalizePhone, phoneError } from '@/lib/phone'
@@ -51,6 +51,13 @@ export function Clients() {
const [editing, setEditing] = useState<Client | null>(null)
const [deleting, setDeleting] = useState<Client | null>(null)
// Filters — `search` is the raw input; `debouncedSearch` is what we query with
// so we don't fire a request on every keystroke.
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [status, setStatus] = useState<ClientStatus | 'all'>('all')
const hasFilters = debouncedSearch.trim() !== '' || status !== 'all'
const [pageSize, setPageSize] = usePageSize()
const [page, setPage] = useState(1)
const pageCount = Math.max(1, Math.ceil(total / pageSize))
@@ -58,12 +65,20 @@ export function Clients() {
// until at least one VLAN exists.
const noVlans = !loading && vlans.length === 0
// Server-side pagination: fetch only the current page's rows.
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 300)
return () => clearTimeout(t)
}, [search])
// Server-side pagination + filtering: fetch only the current page's rows.
const load = useCallback(async () => {
setLoading(true)
try {
const [d, v] = await Promise.all([
api.clients.list(pageSize, (page - 1) * pageSize),
api.clients.list(pageSize, (page - 1) * pageSize, {
search: debouncedSearch,
status: status === 'all' ? undefined : status,
}),
api.vlans.list(),
])
setDevices(d.items)
@@ -75,14 +90,14 @@ export function Clients() {
} finally {
setLoading(false)
}
}, [page, pageSize])
}, [page, pageSize, debouncedSearch, status])
useEffect(() => {
load()
}, [load])
// Reset to the first page when the page size changes.
useEffect(() => setPage(1), [pageSize])
// Reset to the first page when the page size or an active filter changes.
useEffect(() => setPage(1), [pageSize, debouncedSearch, status])
// Clamp the page when the total shrinks (e.g. after a delete).
useEffect(() => {
if (page > pageCount) setPage(pageCount)
@@ -93,7 +108,9 @@ export function Clients() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Clients</h1>
<p className="text-sm text-muted-foreground">{total} registered</p>
<p className="text-sm text-muted-foreground">
{total} {hasFilters ? 'match' : 'registered'}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={load} title="Refresh">
@@ -110,6 +127,52 @@ export function Clients() {
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by MAC, name, phone or alias…"
className="pl-9 pr-9"
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Select value={status} onValueChange={(v) => setStatus(v as ClientStatus | 'all')}>
<SelectTrigger className="sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
{hasFilters && (
<Button
variant="ghost"
onClick={() => {
setSearch('')
setStatus('all')
}}
>
Clear
</Button>
)}
</div>
{noVlans && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
No VLANs exist yet. <Link to="/vlans" className="font-medium underline">Add a VLAN</Link>{' '}
@@ -140,7 +203,9 @@ export function Clients() {
) : devices.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
No devices yet. Add one to get started.
{hasFilters
? 'No clients match your filters.'
: 'No devices yet. Add one to get started.'}
</TableCell>
</TableRow>
) : (