Frontend Architecture¶
This document provides detailed frontend architecture specifications for the sardeenz admin dashboard.
Table of Contents¶
- Overview
- Technology Stack
- Component Hierarchy
- Cluster UI Components
- State Management
- Routing Architecture
- Authentication Flow
- API Integration
- Real-Time Updates
- Key Component Specifications
- Error Handling
- Performance Considerations
Overview¶
The sardeenz frontend is a React-based admin dashboard that enables operators to:
- Monitor all loaded LLM instances and their resource consumption
- Load and unload models dynamically
- View real-time GPU memory metrics and inference statistics
- Manage model configurations and operational state
Design Principles:
- PatternFly-first: Leverage PatternFly 6 components for consistent UX
- Type-safe: TypeScript strict mode throughout
- Accessibility: WCAG 2.1 AA compliance
- Performance: <3s initial load, <200ms interactions
- Security: OAuth 2.0 with role-based access control
Technology Stack¶
| Layer | Technology | Purpose |
|---|---|---|
| UI Framework | React 18.3+ | Component-based UI |
| Language | TypeScript 5.7+ (strict) | Type safety |
| Design System | PatternFly 6.x | Enterprise UI components |
| Routing | React Router 7 | Client-side routing |
| Build Tool | Vite 6.0+ | Fast dev server + bundler |
| HTTP Client | Axios | API communication |
| Testing | Vitest + React Testing Library | Unit/integration tests |
| Mocking | MSW (Mock Service Worker) | API mocking |
| State | React Context API + useState | Local and global state |
Component Hierarchy¶
App (AuthProvider, Router)
├── ProtectedRoute (Auth guard)
│ └── AppLayout (PF Page component)
│ ├── NavSidebar (PF Nav)
│ │ ├── Dashboard link
│ │ ├── Models link
│ │ └── Metrics link
│ └── PageSection (Content area)
│ └── Routes
│ ├── ModelManagement (/)
│ │ ├── ClusterOverview (cluster mode)
│ │ │ ├── SummaryCards (leader, pods, models, GPUs)
│ │ │ └── PodSections (expandable, with model grid)
│ │ ├── PodSelector (cluster mode, target pod dropdown)
│ │ ├── NodeModelPane (left pane)
│ │ │ ├── ModelCard / ModelCardCompact (x N, GPU-grouped)
│ │ │ ├── MoveModelDialog
│ │ │ └── Filters, sorting, view toggle
│ │ ├── NodeModelPane (right pane, split-view)
│ │ ├── GpuMemoryPanel
│ │ ├── LoadModelDialog
│ │ └── SaveConfigurationDialog / LoadConfigurationDialog
│ ├── GpuInfo (/gpu)
│ │ ├── PodSelector (cluster mode)
│ │ └── GPU detail cards per selected pod
│ ├── ModelBenchmark (/benchmark)
│ └── Settings (/settings)
│
└── Shared Components
├── LoadModelDialog (Modal form)
├── SaveConfigurationDialog (Save current models as preset)
├── LoadConfigurationDialog (Load saved configuration)
├── MoveModelDialog (GPU/pod target selection)
├── UnloadModelDialog (Confirmation)
├── ErrorAlert (Notification)
└── LoadingSpinner
Cluster UI Components¶
The frontend supports both single-pod and multi-pod cluster modes. Components detect cluster mode via useClusterStatus() and conditionally render cluster-specific UI or switch between local and cluster API calls.
ClusterOverview (src/components/ClusterOverview.tsx)¶
Purpose: Cluster-level status dashboard displayed at the top of ModelManagement when in cluster mode.
Props:
Features:
- 4 summary cards: Leader ID, Healthy Pods count, Models loaded, Total GPUs
- Expandable pod sections with model grids showing status, GPU assignment, port
- Color-coded health labels: green (healthy), orange (suspect), red (unavailable)
- Leader badge on leader pod
- Polls apiClient.getClusterPods() every 10 seconds
PodSelector (src/components/PodSelector.tsx)¶
Purpose: Dropdown selector for choosing a target pod for model operations (load, move).
Props:
interface PodSelectorProps {
selectedPodId: string | undefined
onSelect: (podId: string) => void
isClusterMode: boolean
label?: string // Default: 'Target pod'
}
Features:
- Only renders when isClusterMode is true
- Fetches pods on mount, shows role (leader/follower) and GPU summary per option
- Disabled options for unavailable pods
- Used in both ModelManagement and GpuInfo pages
NodeModelPane (src/components/NodeModelPane.tsx)¶
Purpose: Displays and manages models loaded on a specific pod (local or remote). Core component for per-pod model management.
Props:
interface NodeModelPaneProps {
podId: string
isLocalPod: boolean
isClusterMode: boolean
gpuMemoryData: MultiGpuMemoryUsageResponse | null
kvCacheTotalByGpu: Record<number, number>
memoryUtilizationByInstance: Record<string, number>
onGpuRefresh: () => void
onModelsChanged?: () => void
isSplitView?: boolean
}
interface NodeModelPaneHandle {
openMoveDialog: (model, targetGpuIds, targetPodId?, modelSourcePodId?) => void
}
API Duality Pattern: Checks isLocalPod to select between local APIs (e.g., apiClient.unloadModelByInstanceId()) and cluster APIs (e.g., apiClient.clusterUnloadModel()).
Features: - Dual view modes: Card view (GPU-grouped) or Table view - Model actions: Unload, Sleep, Wake, Move (with loading states) - Advanced filtering by status, GPU assignment, search term - Sorting by name, load time, memory usage - Imperative handle for triggering move dialog from parent (drag-and-drop integration) - Polls models every 5 seconds
useClusterStatus (src/hooks/useClusterStatus.ts)¶
Purpose: Polls cluster status API and detects leader changes with optional auto-redirect.
interface UseClusterStatusOptions {
onLeaderChange?: (leaderId: string, leaderAddress: string | null) => void
}
interface UseClusterStatusReturn {
clusterStatus: ClusterStatusResponse | null
isClusterMode: boolean
isLoading: boolean
error: string | null
refresh: () => void
}
Features:
- Polls GET /api/cluster every 10 seconds
- Detects leader changes after initial load
- Auto-redirects browser to new leader after 3s delay (internal addresses only)
- Preserves stale data on transient errors
Cluster API Methods (src/services/api.ts)¶
The API client includes cluster-specific methods that proxy operations to the appropriate pod:
Status & Info:
- getClusterStatus() — GET /api/cluster
- getClusterPods() — GET /api/cluster/pods
- getClusterPodGpuAvailability(podId) — GET /api/cluster/pods/{podId}/gpu/available
- getClusterPodGpuInfo(podId) — GET /api/cluster/pods/{podId}/gpu/info
- getClusterPodMemory(podId) — GET /api/cluster/pods/{podId}/memory
- getClusterPodModelsFull(podId) — GET /api/cluster/pods/{podId}/models/full
Model Operations:
- clusterLoadModel(request) — POST /api/cluster/models/load
- clusterUnloadModel(instanceId) — POST /api/cluster/models/{instanceId}/unload
- clusterMoveModel(instanceId, request) — POST /api/cluster/models/{instanceId}/move
- clusterSleepModel(instanceId, level?) — POST /api/cluster/models/{instanceId}/sleep
- clusterWakeModel(instanceId, tags?) — POST /api/cluster/models/{instanceId}/wake
- getClusterModelLogs(instanceId) — GET /api/cluster/models/{instanceId}/logs
Event Streaming:
- subscribeClusterModelEvents(instanceId, onEvent, onError) — SSE: /api/cluster/models/{instanceId}/events
- subscribeClusterMoveEvents(moveId, onEvent, onError) — SSE: /api/cluster/moves/{moveId}/events
Presets & Profiles:
- applyClusterPreset(presetId, dryRun?) — POST /api/cluster/presets/{presetId}/apply
- reconcileClusterMemoryProfiles() — POST /api/cluster/memory-profiles/reconcile
- exportClusterMemoryProfiles() / importClusterMemoryProfiles(request) — Export/import profiles
Cluster State Management¶
Cluster state is managed through a combination of hooks and context:
useClusterStatus(): Cluster status polling and leader detection (hook)AuthContext: Authentication state (existing context, unchanged)NotificationContext: Toast notifications for cluster operationsOperationsContext: Background task tracking (model loads, moves across pods)- Local
useState: Component-level state (selected pod, expanded sections, filters)
Polling Intervals:
| Data | Interval | Component |
|---|---|---|
| Cluster status | 10s | useClusterStatus |
| Pod list | 10s | ClusterOverview |
| Models | 5s | NodeModelPane |
| GPU memory | 5s | NodeModelPane (fallback) |
State Management¶
Strategy¶
- Local State (
useState): Component-specific UI state (loading, form inputs, errors) - Context API: Global state (auth, user role, theme)
- Server State: Fetched directly via axios, stored locally in components
- Future Optimization: React Query for caching, refetching, optimistic updates
State Distribution¶
Local State Examples¶
// Component-level loading state
const [loading, setLoading] = useState(false)
// Form input state
const [modelConfig, setModelConfig] = useState({ name: '', memory: 0.5 })
// UI state
const [isDialogOpen, setIsDialogOpen] = useState(false)
Global State (Context)¶
AuthContext (src/context/AuthContext.tsx):
interface AuthContextType {
user: User | null
isAuthenticated: boolean
role: 'admin' | 'admin-readonly' | null
login: () => void
logout: () => void
refreshToken: () => Promise<void>
}
Usage:
const { user, role, isAuthenticated } = useAuth()
// Conditional rendering based on role
{
role === 'admin' && <LoadModelButton />
}
Server State Pattern¶
const ModelList: React.FC = () => {
const [models, setModels] = useState<Model[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
fetchModels()
const interval = setInterval(fetchModels, 5000) // Poll every 5s
return () => clearInterval(interval)
}, [])
const fetchModels = async () => {
try {
const response = await modelsApi.list()
setModels(response.data)
setError(null)
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}
return <ModelTable models={models} loading={loading} error={error} />
}
Routing Architecture¶
Route Structure¶
| Path | Component | Purpose |
|---|---|---|
/ |
ModelManagement | Model list, load, unload, memory visualization |
/gpu |
GpuInfo | GPU metrics and monitoring |
/benchmark |
ModelBenchmark | Performance testing |
/settings |
Settings | Application configuration |
Centralized Route Configuration¶
Routes are defined in a centralized configuration file (src/routes.tsx) that serves as the single source of truth for both routing and navigation:
// src/routes.tsx
import ModelManagement from './pages/ModelManagement'
import GpuInfo from './pages/GpuInfo'
import ModelBenchmark from './pages/ModelBenchmark'
import Settings from './pages/Settings'
export interface RouteConfig {
path: string
element: JSX.Element
label: string // Navigation display label
itemId: string // NavItem identifier
}
export const routes: RouteConfig[] = [
{
path: '/',
element: <ModelManagement />,
label: 'Model Management',
itemId: 'model-management',
},
{
path: '/gpu',
element: <GpuInfo />,
label: 'GPU Info',
itemId: 'gpu-info',
},
{
path: '/benchmark',
element: <ModelBenchmark />,
label: 'Model Benchmark',
itemId: 'model-benchmark',
},
{
path: '/settings',
element: <Settings />,
label: 'Settings',
itemId: 'settings',
},
]
App Integration with Auto-Generated Routes¶
The App.tsx component imports the route configuration and automatically generates both the route definitions and navigation sidebar:
// src/App.tsx
import { Routes, Route, Link, useLocation } from 'react-router-dom'
import { routes } from './routes'
function App() {
const location = useLocation()
// Auto-generated navigation sidebar from routes
const sidebar = (
<PageSidebar isSidebarOpen={isSidebarOpen}>
<PageSidebarBody>
<Nav>
<NavList>
{routes.map((route) => (
<NavItem
key={route.itemId}
itemId={route.itemId}
isActive={location.pathname === route.path}
>
<Link to={route.path}>{route.label}</Link>
</NavItem>
))}
</NavList>
</Nav>
</PageSidebarBody>
</PageSidebar>
)
// Auto-generated route definitions
return (
<Page masthead={masthead} sidebar={sidebar}>
<Routes>
{routes.map((route) => (
<Route key={route.path} path={route.path} element={route.element} />
))}
</Routes>
</Page>
)
}
Benefits of Centralized Routing¶
- Single Source of Truth: Routes and navigation are defined in one place
- Automatic Synchronization: Navigation automatically updates when routes change
- Type Safety: TypeScript ensures all routes have required metadata
- Easy Maintenance: Adding a new route requires only one change in
routes.tsx - Reduced Duplication: No need to maintain separate route and navigation definitions
Authentication Flow¶
OAuth 2.0 Integration¶
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Browser │ │ Frontend │ │ Backend │
└────┬────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. Navigate to /models │ │
├─────────────────────────>│ │
│ │ │
│ 2. Check auth state │ │
│ (no token) │ │
│ │ │
│ 3. Redirect /auth/login │ │
│<─────────────────────────┤ │
│ │ │
│ 4. GET /auth/login │ │
├──────────────────────────┼──────────────────────────>│
│ │ │
│ 5. 302 to IdP │ │
│<──────────────────────────────────────────────────────┤
│ │ │
│ 6. OAuth flow │ │
│ (user authenticates) │ │
│ │ │
│ 7. Callback with code │ │
├──────────────────────────┼──────────────────────────>│
│ │ │
│ 8. Exchange code → JWT │ │
│<──────────────────────────────────────────────────────┤
│ │ │
│ 9. Store JWT, redirect │ │
│ to /models │ │
├─────────────────────────>│ │
│ │ │
│ 10. Render protected │ │
│ route │ │
│<─────────────────────────┤ │
JWT Handling¶
Storage: In-memory only (NOT localStorage)
// src/api/client.ts
let jwtToken: string | null = null
export const setToken = (token: string) => {
jwtToken = token
}
export const getToken = () => jwtToken
export const clearToken = () => {
jwtToken = null
}
Axios Interceptor:
import axios from 'axios'
const apiClient = axios.create({
baseURL: '/api/v1',
timeout: 10000,
})
// Request interceptor: Add JWT to all requests
apiClient.interceptors.request.use((config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor: Handle 401 (redirect to login)
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
clearToken()
window.location.href = '/login'
}
return Promise.reject(error)
}
)
API Integration¶
API Client Structure¶
src/api/
├── client.ts # Axios instance + interceptors
├── models.ts # Model endpoints
├── metrics.ts # Metrics endpoints
└── types.ts # API response types
API Modules¶
client.ts - Base configuration:
import axios, { AxiosInstance } from 'axios'
export const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// Add interceptors (auth, error handling)
models.ts - Model operations:
import { apiClient } from './client'
import { ModelInstance, LoadModelRequest, LoadModelResponse } from './types'
export const modelsApi = {
list: () => apiClient.get<ModelInstance[]>('/models'),
get: (id: string) => apiClient.get<ModelInstance>(`/models/${id}`),
load: (request: LoadModelRequest) => apiClient.post<LoadModelResponse>('/models/load', request),
unload: (id: string) => apiClient.post(`/models/${id}/unload`),
status: (id: string) => apiClient.get<ModelInstance>(`/models/${id}/status`),
}
metrics.ts - Metrics endpoints:
import { apiClient } from './client'
import { ResourceMetrics, SystemMetrics } from './types'
export const metricsApi = {
getModelMetrics: (id: string) => apiClient.get<ResourceMetrics>(`/metrics/models/${id}`),
getSystemMetrics: () => apiClient.get<SystemMetrics>('/metrics/system'),
getAll: () => apiClient.get<ResourceMetrics[]>('/metrics'),
}
Custom Hooks for API Calls¶
src/hooks/useModels.ts:
import { useState, useEffect } from 'react'
import { modelsApi } from '../api/models'
import { ModelInstance } from '../api/types'
export function useModels(refreshInterval = 5000) {
const [models, setModels] = useState<ModelInstance[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchModels = async () => {
try {
const response = await modelsApi.list()
setModels(response.data)
setError(null)
} catch (err) {
setError(err as Error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchModels()
const interval = setInterval(fetchModels, refreshInterval)
return () => clearInterval(interval)
}, [refreshInterval])
return { models, loading, error, refetch: fetchModels }
}
Real-Time Updates¶
Polling Strategy (MVP)¶
- Model status: Poll
/api/v1/modelsevery 5 seconds - GPU metrics: Poll
/api/v1/metricsevery 2 seconds - Stop polling: When component unmounts or user navigates away
Polling Hook¶
src/hooks/usePolling.ts:
import { useState, useEffect, useRef } from 'react'
export function usePolling<T>(fetchFn: () => Promise<T>, interval: number, enabled = true) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(true)
const isMountedRef = useRef(true)
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
}
}, [])
useEffect(() => {
if (!enabled) return
const poll = async () => {
try {
const result = await fetchFn()
if (isMountedRef.current) {
setData(result)
setError(null)
}
} catch (err) {
if (isMountedRef.current) {
setError(err as Error)
}
} finally {
if (isMountedRef.current) {
setLoading(false)
}
}
}
poll() // Initial fetch
const timer = setInterval(poll, interval)
return () => clearInterval(timer)
}, [fetchFn, interval, enabled])
return { data, error, loading }
}
Future: WebSocket Integration¶
Planned architecture (post-MVP):
// src/hooks/useWebSocket.ts
export function useWebSocket(url: string) {
const [data, setData] = useState(null)
const [connected, setConnected] = useState(false)
useEffect(() => {
const ws = new WebSocket(url)
ws.onopen = () => setConnected(true)
ws.onmessage = (event) => setData(JSON.parse(event.data))
ws.onclose = () => setConnected(false)
return () => ws.close()
}, [url])
return { data, connected }
}
Key Component Specifications¶
1. ModelCard / ModelCardCompact¶
Purpose: Display a single model instance with status, GPU placement, and actions.
Props:
interface ModelCardProps {
model: ModelInstanceDTO
onUnload: (instanceId: string, modelPath: string, isFailed: boolean) => void
onSleep?: (instanceId: string) => void
onWake?: (instanceId: string) => void
isUnloading?: boolean
isSleeping?: boolean
isWaking?: boolean
}
PatternFly Components:
Card,CardTitle,CardBody,CardFooterDescriptionList(for model details)Badge(viaModelStatusBadgefor status)Button(for actions)ExpandableSection(for launch command)DropdownwithDropdownItemfor actions menuMoonIcon,SunIcon(for sleep/wake actions)
Layout:
┌─────────────────────────────────────┐
│ meta-llama/Llama-3.2-1B [Running] │
│ │
│ Served model name: Llama-3.2-1B │
│ Port: 8001 │
│ Max Tokens: 4096 │
│ GPU Memory: 80% Details │
│ GPU: 0 (or GPUs: 0, 1 (...)) │
│ Started at: 12/5/2025, 10:30 AM │
│ │
│ ▸ Launch Command │
│ │
│ [Unload] │
└─────────────────────────────────────┘
GPU Display:
- Single GPU: Shows "GPU 0"
- Multiple GPUs: Shows "GPU 0, GPU 1 (tensor parallel)"
- The "(tensor parallel)" suffix indicates model is split across GPUs
2. LoadModelDialog¶
Purpose: Modal form for loading a new model instance.
Props:
interface LoadModelDialogProps {
isOpen: boolean
onClose: () => void
onLoad: (config: LoadModelConfig) => Promise<void>
}
interface LoadModelConfig {
modelPath: string
displayName: string
gpuMemoryLimit: number // 0.1 - 0.9
port?: number // Optional, auto-assign if not provided
enableSleepMode?: boolean // Enable sleep mode for this model
}
PatternFly Components:
ModalForm,FormGroupTextInput(model path, display name)Slider(GPU memory allocation)NumberInput(port, optional)Checkbox(Enable Sleep Mode)Button(Load, Cancel)
Validation:
- Model path: Required, must exist
- Display name: Required, max 50 chars
- GPU memory: 0.1-0.9 (10%-90%)
- Port: Optional, 1024-65535
- Enable Sleep Mode: Optional checkbox to allow the model to be put to sleep later
3. MemoryUsageChart¶
Purpose: Real-time visualization of GPU memory allocation.
Props:
interface MemoryUsageChartProps {
totalMemory: number // Total GPU memory in GB
models: Array<{
id: string
name: string
memoryUsed: number
color: string
}>
}
Chart Type: Stacked bar chart or donut chart
PatternFly Components:
Card,CardBody- PatternFly Charts (Victory-based)
Data Source: /api/v1/metrics (poll every 2-5 seconds)
4. ModelStatusBadge¶
Purpose: Visual status indicator for models.
Props:
interface ModelStatusBadgeProps {
status: 'starting' | 'active' | 'sleeping' | 'stopping' | 'failed'
}
PatternFly Components:
BadgeSpinner(for 'starting', 'stopping' status)MoonIcon(for 'sleeping' status)
Color Mapping:
starting→ Blue badge with spinneractive→ Green badgesleeping→ Purple badge with moon iconstopping→ Orange badge with spinnerfailed→ Red badge
Error Handling¶
Error Types¶
- Network Errors (offline, timeout)
- Authentication Errors (401, 403)
- Validation Errors (400)
- Server Errors (500, 503)
- Resource Errors (insufficient GPU memory)
Error Display Pattern¶
import { Alert, AlertActionCloseButton } from '@patternfly/react-core'
const ErrorAlert: React.FC<{ error: Error; onClose: () => void }> = ({ error, onClose }) => {
const variant = error.response?.status === 401 ? 'danger' : 'warning'
const title = error.response?.status === 401 ? 'Authentication Required' : 'Error'
return (
<Alert
variant={variant}
title={title}
actionClose={<AlertActionCloseButton onClose={onClose} />}
isInline
>
{error.message}
</Alert>
)
}
Global Error Boundary (Future)¶
// src/components/ErrorBoundary.tsx
class ErrorBoundary extends React.Component<Props, State> {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error boundary caught:', error, errorInfo)
// Log to monitoring service
}
render() {
if (this.state.hasError) {
return <ErrorPage />
}
return this.props.children
}
}
Performance Considerations¶
Bundle Size Optimization¶
- Code splitting: Use React.lazy() for routes
- Tree shaking: Vite handles automatically
- PatternFly imports: Import specific components, not entire library
Example:
// ✅ Good - Tree-shakeable
import { Button, Card } from '@patternfly/react-core'
// ❌ Bad - Imports entire library
import * as PF from '@patternfly/react-core'
Rendering Optimization¶
Memoization for expensive components:
import { memo } from 'react'
const ModelCard = memo<ModelCardProps>(
({ model, onUnload }) => {
// Component logic
},
(prevProps, nextProps) => {
// Custom comparison: only re-render if model data changed
return (
prevProps.model.id === nextProps.model.id && prevProps.model.status === nextProps.model.status
)
}
)
Debouncing for search/filter:
import { useState, useCallback } from 'react'
import { debounce } from 'lodash-es'
const ModelSearch: React.FC = () => {
const [search, setSearch] = useState('')
const debouncedSearch = useCallback(
debounce((value: string) => {
// Perform search API call
}, 300),
[]
)
const handleChange = (value: string) => {
setSearch(value)
debouncedSearch(value)
}
return <TextInput value={search} onChange={handleChange} />
}
Polling Optimization¶
- Adaptive polling: Increase interval when user is inactive
- Pause when hidden: Use Page Visibility API to stop polling when tab is hidden
- Conditional polling: Only poll on relevant pages
import { useEffect, useState } from 'react'
function useVisibilityChange() {
const [isVisible, setIsVisible] = useState(!document.hidden)
useEffect(() => {
const handleVisibilityChange = () => {
setIsVisible(!document.hidden)
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [])
return isVisible
}
// Usage in polling hook
const isVisible = useVisibilityChange()
const { data } = usePolling(fetchFn, 5000, isVisible)
Testing Strategy¶
Unit Tests¶
Test individual components in isolation with mocked dependencies.
// ModelCard.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ModelCard } from './ModelCard'
describe('ModelCard', () => {
const mockModel = {
id: 'model-1',
displayName: 'llama-2-7b',
status: 'active' as const,
port: 5001,
gpuMemoryLimit: 6.0,
// ... other fields
}
it('renders model information correctly', () => {
render(<ModelCard model={mockModel} onUnload={vi.fn()} userRole="admin" />)
expect(screen.getByText('llama-2-7b')).toBeInTheDocument()
expect(screen.getByText('Port: 5001')).toBeInTheDocument()
})
it('calls onUnload when unload button clicked (admin only)', async () => {
const user = userEvent.setup()
const onUnload = vi.fn()
render(<ModelCard model={mockModel} onUnload={onUnload} userRole="admin" />)
const unloadButton = screen.getByRole('button', { name: /unload/i })
await user.click(unloadButton)
expect(onUnload).toHaveBeenCalledWith('model-1')
})
it('hides unload button for read-only users', () => {
render(<ModelCard model={mockModel} onUnload={vi.fn()} userRole="admin-readonly" />)
expect(screen.queryByRole('button', { name: /unload/i })).not.toBeInTheDocument()
})
})
Integration Tests¶
Test API integration with MSW (Mock Service Worker).
// ModelManagement.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { ModelManagement } from './ModelManagement'
const server = setupServer(
rest.get('/api/v1/models', (req, res, ctx) => {
return res(ctx.json([mockModel1, mockModel2]))
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('loads and displays models', async () => {
render(<ModelManagement />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('llama-2-7b')).toBeInTheDocument()
expect(screen.getByText('mistral-7b')).toBeInTheDocument()
})
})
Accessibility Guidelines¶
WCAG 2.1 AA Compliance¶
- Keyboard navigation: All interactive elements must be keyboard-accessible
- Screen readers: Proper ARIA labels and semantic HTML
- Color contrast: Minimum 4.5:1 for text, 3:1 for large text
- Focus indicators: Visible focus states on all interactive elements
Example Accessibility Implementation¶
<Button
aria-label="Unload llama-2-7b model"
onClick={() => onUnload(model.id)}
>
Unload
</Button>
<ProgressBar
value={70}
min={0}
max={100}
title="GPU memory usage"
aria-label="GPU memory usage: 70%"
/>
<Table aria-label="Model instances list">
{/* Table content */}
</Table>
Related Documentation¶
- PatternFly 6 Guide - Component library documentation
- Frontend API Client - API integration guide
- Frontend CLAUDE.md - Development context
- Architecture - System-wide architecture overview