Frontend API Client Guide¶
This guide explains how the frontend integrates with the Controller API for model management and monitoring.
Table of Contents¶
- Overview
- API Client Setup
- Authentication Integration
- API Modules
- Custom Hooks
- Error Handling
- TypeScript Types
- Usage Examples
- Testing with MSW
Overview¶
The frontend communicates with the Controller API (http://localhost:3000/api/v1) to:
- Load and unload model instances
- Query model status and configuration
- Retrieve resource metrics (GPU memory, request counts)
- Access operation audit trail
Key Requirements:
- OAuth 2.0 authentication with JWT bearer tokens
- Role-based access control (admin, admin-readonly)
- Automatic token refresh
- Graceful error handling
- TypeScript types from
packages/types
API Client Setup¶
Directory Structure¶
src/api/
├── client.ts # Axios instance with interceptors
├── models.ts # Model lifecycle endpoints
├── metrics.ts # Metrics endpoints
├── types.ts # API response types (imports from @sardeenz/types)
└── index.ts # Re-export all APIs
Base Client Configuration¶
src/api/client.ts:
import axios, { AxiosInstance, AxiosError } from 'axios'
// Token storage (in-memory only for security)
let jwtToken: string | null = null
export const setToken = (token: string) => {
jwtToken = token
}
export const getToken = (): string | null => jwtToken
export const clearToken = () => {
jwtToken = null
}
// Create axios instance
export const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor: Add JWT to all requests
apiClient.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor: Handle auth errors
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
// Token expired or invalid - redirect to login
clearToken()
window.location.href = '/login'
}
return Promise.reject(error)
}
)
Environment Variables¶
.env (development):
.env.production:
Authentication Integration¶
OAuth 2.0 Flow¶
The frontend delegates OAuth authentication to the backend:
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Browser │ │ Frontend │ │ Backend │
└────┬────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. Navigate to / │ │
├─────────────────────────>│ │
│ │ │
│ 2. No token? Redirect │ │
│ to /auth/login │ │
│<─────────────────────────┤ │
│ │ │
│ 3. GET /auth/login │ │
├──────────────────────────┼──────────────────────────>│
│ │ │
│ 4. 302 to IdP │ │
│<──────────────────────────────────────────────────────┤
│ │ │
│ 5. User authenticates │ │
│ at IdP │ │
│ │ │
│ 6. Callback with code │ │
├──────────────────────────┼──────────────────────────>│
│ │ │
│ 7. Backend exchanges │ │
│ code → JWT │ │
│ │ │
│ 8. Redirect to / with │ │
│ JWT in memory │ │
│<──────────────────────────────────────────────────────┤
AuthContext Implementation¶
src/context/AuthContext.tsx:
import React, { createContext, useContext, useState, useEffect } from 'react'
import axios from 'axios'
import { setToken, clearToken } from '../api/client'
interface User {
id: string
email: string
name: string
role: 'admin' | 'admin-readonly'
}
interface AuthContextType {
user: User | null
isAuthenticated: boolean
loading: boolean
login: () => void
logout: () => void
}
const AuthContext = createContext<AuthContextType | null>(null)
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// Check if user is already authenticated
checkAuth()
}, [])
const checkAuth = async () => {
try {
const response = await axios.get('/auth/me')
setUser(response.data.user)
setToken(response.data.token)
} catch (error) {
setUser(null)
clearToken()
} finally {
setLoading(false)
}
}
const login = () => {
window.location.href = '/auth/login'
}
const logout = async () => {
try {
await axios.post('/auth/logout')
} finally {
setUser(null)
clearToken()
window.location.href = '/login'
}
}
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
loading,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
)
}
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
API Modules¶
Models API¶
src/api/models.ts:
import { apiClient } from './client'
import { ModelInstance } from '@sardeenz/types'
export interface LoadModelRequest {
modelPath: string
displayName: string
gpuMemoryLimit: number // GB
port?: number // Optional, auto-assign if not provided
}
export interface LoadModelResponse {
id: string
modelPath: string
displayName: string
status: 'starting' | 'active' | 'stopping' | 'failed'
port: number
gpuMemoryLimit: number
createdAt: string
}
export interface ModelListResponse {
models: ModelInstance[]
total: number
}
export const modelsApi = {
/**
* List all model instances
*/
list: () => apiClient.get<ModelListResponse>('/models'),
/**
* Get a specific model instance by ID
*/
get: (id: string) => apiClient.get<ModelInstance>(`/models/${id}`),
/**
* Load a new model instance
* Requires 'admin' role
*/
load: (request: LoadModelRequest) => apiClient.post<LoadModelResponse>('/models/load', request),
/**
* Unload a running model instance
* Requires 'admin' role
*/
unload: (id: string) => apiClient.post(`/models/${id}/unload`),
/**
* Get current status of a model instance
*/
status: (id: string) => apiClient.get<ModelInstance>(`/models/${id}/status`),
/**
* Get logs for a model instance
*/
logs: (id: string, lines = 100) =>
apiClient.get<{ logs: string[] }>(`/models/${id}/logs`, {
params: { lines },
}),
}
Metrics API¶
src/api/metrics.ts:
import { apiClient } from './client'
export interface ResourceMetrics {
modelId: string
timestamp: string
gpuMemoryUsed: number // GB
requestCount: number
activeConnections: number
avgResponseTime: number // ms
p95ResponseTime: number // ms
}
export interface SystemMetrics {
timestamp: string
totalGpuMemory: number // GB
usedGpuMemory: number // GB
freeGpuMemory: number // GB
gpuUtilization: number // 0-100
models: Array<{
id: string
name: string
memoryUsed: number
}>
}
export const metricsApi = {
/**
* Get metrics for a specific model
*/
getModelMetrics: (id: string) => apiClient.get<ResourceMetrics>(`/metrics/models/${id}`),
/**
* Get system-wide metrics
*/
getSystemMetrics: () => apiClient.get<SystemMetrics>('/metrics/system'),
/**
* Get metrics for all models
*/
getAllMetrics: () => apiClient.get<ResourceMetrics[]>('/metrics'),
/**
* Get time-series metrics for a model
* @param id Model instance ID
* @param duration Duration in seconds (default: 300 = 5 minutes)
* @param interval Data point interval in seconds (default: 5)
*/
getTimeSeries: (id: string, duration = 300, interval = 5) =>
apiClient.get<ResourceMetrics[]>(`/metrics/models/${id}/timeseries`, {
params: { duration, interval },
}),
}
Index Re-exports¶
src/api/index.ts:
Custom Hooks¶
useModels Hook¶
src/hooks/useModels.ts:
import { useState, useEffect, useCallback } from 'react'
import { modelsApi } from '../api/models'
import { ModelInstance } from '@sardeenz/types'
interface UseModelsResult {
models: ModelInstance[]
loading: boolean
error: Error | null
refetch: () => Promise<void>
}
/**
* Hook for fetching and managing model instances with automatic polling
* @param refreshInterval Polling interval in ms (default: 5000 = 5 seconds)
* @param enabled Enable/disable automatic polling (default: true)
*/
export function useModels(refreshInterval = 5000, enabled = true): UseModelsResult {
const [models, setModels] = useState<ModelInstance[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchModels = useCallback(async () => {
try {
const response = await modelsApi.list()
setModels(response.data.models)
setError(null)
} catch (err) {
console.error('Error fetching models:', err)
setError(err as Error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!enabled) return
fetchModels()
const interval = setInterval(fetchModels, refreshInterval)
return () => clearInterval(interval)
}, [fetchModels, refreshInterval, enabled])
return { models, loading, error, refetch: fetchModels }
}
useModelDetails Hook¶
src/hooks/useModelDetails.ts:
import { useState, useEffect } from 'react'
import { modelsApi } from '../api/models'
import { ModelInstance } from '@sardeenz/types'
interface UseModelDetailsResult {
model: ModelInstance | null
loading: boolean
error: Error | null
refetch: () => Promise<void>
}
/**
* Hook for fetching details of a specific model instance
* @param id Model instance ID
* @param refreshInterval Polling interval in ms (default: 5000)
*/
export function useModelDetails(id: string, refreshInterval = 5000): UseModelDetailsResult {
const [model, setModel] = useState<ModelInstance | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchModel = async () => {
try {
const response = await modelsApi.get(id)
setModel(response.data)
setError(null)
} catch (err) {
console.error(`Error fetching model ${id}:`, err)
setError(err as Error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchModel()
const interval = setInterval(fetchModel, refreshInterval)
return () => clearInterval(interval)
}, [id, refreshInterval])
return { model, loading, error, refetch: fetchModel }
}
useLoadModel Hook¶
src/hooks/useLoadModel.ts:
import { useState } from 'react'
import { modelsApi, LoadModelRequest } from '../api/models'
interface UseLoadModelResult {
loadModel: (config: LoadModelRequest) => Promise<void>
loading: boolean
error: Error | null
}
/**
* Hook for loading a new model instance
*/
export function useLoadModel(): UseLoadModelResult {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const loadModel = async (config: LoadModelRequest) => {
setLoading(true)
setError(null)
try {
await modelsApi.load(config)
// Success - model is now in 'starting' state
} catch (err) {
console.error('Error loading model:', err)
setError(err as Error)
throw err // Re-throw for component error handling
} finally {
setLoading(false)
}
}
return { loadModel, loading, error }
}
useMetrics Hook¶
src/hooks/useMetrics.ts:
import { useState, useEffect } from 'react'
import { metricsApi, SystemMetrics } from '../api/metrics'
interface UseMetricsResult {
metrics: SystemMetrics | null
loading: boolean
error: Error | null
refetch: () => Promise<void>
}
/**
* Hook for fetching system metrics with automatic polling
* @param refreshInterval Polling interval in ms (default: 2000 = 2 seconds)
* @param enabled Enable/disable automatic polling
*/
export function useMetrics(refreshInterval = 2000, enabled = true): UseMetricsResult {
const [metrics, setMetrics] = useState<SystemMetrics | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchMetrics = async () => {
try {
const response = await metricsApi.getSystemMetrics()
setMetrics(response.data)
setError(null)
} catch (err) {
console.error('Error fetching metrics:', err)
setError(err as Error)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!enabled) return
fetchMetrics()
const interval = setInterval(fetchMetrics, refreshInterval)
return () => clearInterval(interval)
}, [refreshInterval, enabled])
return { metrics, loading, error, refetch: fetchMetrics }
}
Error Handling¶
Error Types¶
// src/api/types.ts
export interface ApiError {
message: string
code: string
status: number
details?: Record<string, unknown>
}
export class ApiErrorHandler {
static parse(error: unknown): ApiError {
if (axios.isAxiosError(error)) {
return {
message: error.response?.data?.message || error.message,
code: error.response?.data?.code || 'UNKNOWN_ERROR',
status: error.response?.status || 500,
details: error.response?.data?.details,
}
}
return {
message: 'An unexpected error occurred',
code: 'UNEXPECTED_ERROR',
status: 500,
}
}
static isAuthError(error: ApiError): boolean {
return error.status === 401 || error.status === 403
}
static isValidationError(error: ApiError): boolean {
return error.status === 400
}
static isResourceError(error: ApiError): boolean {
return error.code === 'INSUFFICIENT_GPU_MEMORY' || error.code === 'MODEL_ALREADY_LOADED'
}
}
Error Display Component¶
// src/components/ErrorAlert.tsx
import React from 'react'
import { Alert, AlertActionCloseButton } from '@patternfly/react-core'
import { ApiError, ApiErrorHandler } from '../api/types'
interface ErrorAlertProps {
error: Error | ApiError
onClose: () => void
}
export const ErrorAlert: React.FC<ErrorAlertProps> = ({ error, onClose }) => {
const apiError = ApiErrorHandler.parse(error)
const variant = apiError.status >= 500 ? 'danger' : 'warning'
const title = apiError.status >= 500 ? 'Server Error' : 'Request Failed'
return (
<Alert
variant={variant}
title={title}
actionClose={<AlertActionCloseButton onClose={onClose} />}
isInline
>
{apiError.message}
{apiError.details && (
<pre style={{ marginTop: '1rem' }}>{JSON.stringify(apiError.details, null, 2)}</pre>
)}
</Alert>
)
}
TypeScript Types¶
Shared Types from packages/types¶
// src/api/types.ts
// Import shared types from monorepo packages
export type {
ModelInstance,
ModelConfiguration,
ResourceMetrics,
ControllerOperation,
} from '@sardeenz/types'
// Frontend-specific types
export interface ModelFormData {
modelPath: string
displayName: string
gpuMemoryLimit: number
port?: number
}
export interface ModelFilters {
status?: Array<'starting' | 'active' | 'stopping' | 'failed'>
search?: string
}
Usage Examples¶
Loading a Model¶
// src/components/LoadModelDialog.tsx
import React, { useState } from 'react'
import { Modal, Button, Form, FormGroup, TextInput, Slider } from '@patternfly/react-core'
import { useLoadModel } from '../hooks/useLoadModel'
export const LoadModelDialog: React.FC<Props> = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
modelPath: '',
displayName: '',
gpuMemoryLimit: 4.0,
})
const { loadModel, loading } = useLoadModel()
const handleSubmit = async () => {
try {
await loadModel(formData)
onSuccess()
onClose()
} catch (error) {
// Error is handled by the hook and stored in error state
// Display error in UI
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Load Model">
<Form>
<FormGroup label="Model Path" isRequired>
<TextInput
value={formData.modelPath}
onChange={(value) => setFormData({ ...formData, modelPath: value })}
/>
</FormGroup>
<FormGroup label="Display Name" isRequired>
<TextInput
value={formData.displayName}
onChange={(value) => setFormData({ ...formData, displayName: value })}
/>
</FormGroup>
<FormGroup label="GPU Memory Limit (GB)">
<Slider
value={formData.gpuMemoryLimit}
min={0.5}
max={24}
step={0.5}
onChange={(value) => setFormData({ ...formData, gpuMemoryLimit: value })}
/>
</FormGroup>
<Button onClick={handleSubmit} isLoading={loading}>
Load Model
</Button>
</Form>
</Modal>
)
}
Displaying Model List¶
// src/pages/ModelManagement.tsx
import React from 'react'
import { Page, PageSection, Spinner, Alert } from '@patternfly/react-core'
import { useModels } from '../hooks/useModels'
import { ModelTable } from '../components/ModelTable'
export const ModelManagement: React.FC = () => {
const { models, loading, error } = useModels(5000) // Poll every 5 seconds
if (loading && models.length === 0) {
return <Spinner />
}
if (error) {
return (
<Alert variant="danger" title="Error loading models">
{error.message}
</Alert>
)
}
return (
<Page>
<PageSection>
<ModelTable models={models} />
</PageSection>
</Page>
)
}
Testing with MSW¶
MSW Setup¶
src/test/mocks/handlers.ts:
import { rest } from 'msw'
export const handlers = [
// List models
rest.get('/api/v1/models', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
models: [
{
id: 'model-1',
displayName: 'Llama 2 7B',
status: 'active',
port: 5001,
gpuMemoryLimit: 6.0,
createdAt: '2025-11-11T10:00:00Z',
},
],
total: 1,
})
)
}),
// Load model
rest.post('/api/v1/models/load', async (req, res, ctx) => {
const body = await req.json()
return res(
ctx.status(202),
ctx.json({
id: 'model-new',
...body,
status: 'starting',
createdAt: new Date().toISOString(),
})
)
}),
// Get metrics
rest.get('/api/v1/metrics/system', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
timestamp: new Date().toISOString(),
totalGpuMemory: 24.0,
usedGpuMemory: 12.5,
freeGpuMemory: 11.5,
gpuUtilization: 52,
models: [{ id: 'model-1', name: 'Llama 2 7B', memoryUsed: 6.2 }],
})
)
}),
]
Test Setup¶
src/test/setup.ts:
import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'
export const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
Test Example¶
import { render, screen, waitFor } from '@testing-library/react'
import { ModelManagement } from './ModelManagement'
test('displays model list', async () => {
render(<ModelManagement />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Llama 2 7B')).toBeInTheDocument()
})
})
Related Documentation¶
- Frontend Architecture - Component architecture and state management
- Frontend CLAUDE.md - Development context and guidelines
- API Guide - Complete Controller API reference
- Backend CLAUDE.md - Backend API implementation