36 KiB
Web UI Architecture
Created: 2024-01-18
Status: Planning
Tech Stack: React 18 + TypeScript + Vite
Overview
The Attune Web UI is a single-page application (SPA) that provides a comprehensive interface for managing and monitoring the Attune automation platform. It communicates with the Attune API service via REST endpoints and receives real-time updates through WebSocket connections to the notifier service.
Architecture Principles
- Type Safety: Full TypeScript coverage with types generated from OpenAPI specification
- Real-time Updates: WebSocket integration for live execution monitoring and notifications
- Offline-first Caching: Intelligent client-side caching with optimistic updates
- Component Reusability: Shared components for common patterns (lists, forms, detail views)
- Performance: Code splitting, lazy loading, and efficient re-rendering
- Developer Experience: Fast dev server, hot module replacement, clear error messages
Technology Stack
Core Framework
React 18
- Purpose: UI component library and rendering engine
- Why: Industry standard, mature ecosystem, excellent for complex UIs
- Key Features Used:
- Hooks for state management (useState, useEffect, useContext)
- Suspense for code splitting and async data loading
- Concurrent rendering for improved UX
- Error boundaries for graceful error handling
TypeScript 5.x
- Purpose: Type safety and improved developer experience
- Why: Catches errors at compile time, better IDE support, self-documenting code
- Configuration:
- Strict mode enabled
- Path aliases for clean imports (@/components, @/api, @/hooks)
- Integration with OpenAPI-generated types
Vite
- Purpose: Build tool and dev server
- Why: Fast HMR, optimized production builds, native ESM support
- Features:
- Sub-second dev server startup
- Instant hot module replacement
- Optimized production builds with Rollup
- Environment variable management
- Plugin ecosystem (React, TypeScript support out of the box)
API Layer
OpenAPI TypeScript Codegen
- Purpose: Generate type-safe API client from OpenAPI spec
- Why: Single source of truth, automatic updates when API changes
- Generated Artifacts:
- TypeScript interfaces for all DTOs (requests/responses)
- API client with methods for all 86 endpoints
- Type-safe parameter validation
- Automatic bearer token injection
Usage Pattern:
// Generated client usage
import { ActionsService } from '@/api/services/ActionsService';
// Type-safe API call with auto-complete
const actions = await ActionsService.listActions({
limit: 50,
offset: 0
});
// actions is typed as ApiResponse<PaginatedResponse<ActionSummary[]>>
Code Generation Command:
# Run whenever API spec changes
npm run generate:api
# Implemented as:
openapi-typescript-codegen \
--input http://localhost:8080/api-spec/openapi.json \
--output ./src/api \
--client axios \
--useOptions
Configuration (openapi-codegen.config.json):
- Client: axios (configurable for auth, interceptors)
- Type generation: all request/response types
- Service generation: one service class per tag (PacksService, ActionsService, etc.)
- Enum handling: TypeScript enums for all OpenAPI enums
Axios
- Purpose: HTTP client for API requests
- Why: Interceptors for auth, request/response transformation, browser/node compatibility
- Configuration:
- Base URL from environment variable
- Request interceptor: inject JWT token from storage
- Response interceptor: handle 401 (refresh token), network errors
- Timeout configuration
- Request/response logging in development
Axios Instance Setup:
// src/api/client.ts
import axios from 'axios';
import { getAccessToken, refreshAccessToken } from '@/auth/tokens';
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor: inject auth token
apiClient.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor: handle auth errors
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If 401 and not already retried, attempt token refresh
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
await refreshAccessToken();
return apiClient(originalRequest);
} catch (refreshError) {
// Refresh failed, redirect to login
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
Data Fetching & Caching
TanStack Query (React Query v5)
- Purpose: Server state management, caching, and synchronization
- Why: Eliminates boilerplate, automatic caching, background refetching, optimistic updates
- Key Features:
- Automatic background refetching
- Cache invalidation and updates
- Optimistic updates for better UX
- Request deduplication
- Pagination and infinite scroll support
- Prefetching for improved perceived performance
Query Usage Patterns:
// src/hooks/useActions.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ActionsService } from '@/api/services/ActionsService';
// Query for list of actions
export function useActions(params?: { pack_ref?: string; limit?: number }) {
return useQuery({
queryKey: ['actions', params],
queryFn: () => ActionsService.listActions(params),
staleTime: 30000, // Consider fresh for 30s
gcTime: 5 * 60 * 1000, // Keep in cache for 5 min
});
}
// Query for single action
export function useAction(ref: string) {
return useQuery({
queryKey: ['actions', ref],
queryFn: () => ActionsService.getActionByRef({ ref }),
enabled: !!ref, // Only fetch if ref is provided
});
}
// Mutation for creating action
export function useCreateAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ActionsService.createAction,
onSuccess: (newAction) => {
// Invalidate action list to trigger refetch
queryClient.invalidateQueries({ queryKey: ['actions'] });
// Optimistically update cache with new action
queryClient.setQueryData(['actions', newAction.ref], newAction);
},
});
}
// Mutation with optimistic update
export function useUpdateAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ ref, data }: { ref: string; data: UpdateActionRequest }) =>
ActionsService.updateAction({ ref, requestBody: data }),
onMutate: async ({ ref, data }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['actions', ref] });
// Snapshot previous value
const previous = queryClient.getQueryData(['actions', ref]);
// Optimistically update
queryClient.setQueryData(['actions', ref], (old: any) => ({
...old,
...data,
}));
return { previous };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(['actions', variables.ref], context?.previous);
},
onSettled: (data, error, variables) => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: ['actions', variables.ref] });
},
});
}
Query Key Strategy:
- Use hierarchical keys:
['resource', params] - Examples:
['actions']- all actions['actions', { pack_ref: 'core' }]- actions filtered by pack['actions', 'core.http']- specific action['executions', { status: 'running' }]- filtered executions
Cache Invalidation Patterns:
- After mutations: invalidate related queries
- WebSocket updates: update specific cache entries
- Manual refresh: invalidate and refetch
- Periodic background updates for critical data (running executions)
Authentication
JWT Token Management
- Storage: Access token in memory, refresh token in httpOnly cookie (if backend supports) or localStorage
- Flow:
- User logs in → receives access token (1h) + refresh token (7d)
- Store tokens securely
- Axios interceptor adds access token to all requests
- On 401 response, attempt refresh
- If refresh succeeds, retry original request
- If refresh fails, redirect to login
Auth Context:
// src/contexts/AuthContext.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import { AuthService } from '@/api/services/AuthService';
interface User {
id: number;
username: string;
email: string;
roles: string[];
}
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Load user on mount
useEffect(() => {
loadUser();
}, []);
const loadUser = async () => {
try {
const response = await AuthService.getCurrentUser();
setUser(response.data);
} catch (error) {
setUser(null);
} finally {
setIsLoading(false);
}
};
const login = async (username: string, password: string) => {
const response = await AuthService.login({ requestBody: { username, password } });
localStorage.setItem('access_token', response.data.access_token);
localStorage.setItem('refresh_token', response.data.refresh_token);
await loadUser();
};
const logout = async () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
setUser(null);
};
return (
<AuthContext.Provider value={{
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
refreshUser: loadUser,
}}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}
Protected Routes:
// src/components/ProtectedRoute.tsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <LoadingSpinner />;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
Real-time Updates
WebSocket Client
- Purpose: Receive real-time notifications from notifier service
- Connection: WebSocket to notifier service (separate from API)
- Protocol: JSON messages with event types
- Reconnection: Automatic reconnection with exponential backoff
WebSocket Integration:
// src/websocket/client.ts
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
interface NotificationMessage {
type: 'execution.started' | 'execution.completed' | 'execution.failed' |
'inquiry.created' | 'event.created';
data: any;
timestamp: string;
}
export function useWebSocketNotifications() {
const wsRef = useRef<WebSocket | null>(null);
const queryClient = useQueryClient();
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const reconnectAttempts = useRef(0);
useEffect(() => {
const connect = () => {
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8081';
const token = localStorage.getItem('access_token');
wsRef.current = new WebSocket(`${wsUrl}?token=${token}`);
wsRef.current.onopen = () => {
console.log('WebSocket connected');
reconnectAttempts.current = 0;
};
wsRef.current.onmessage = (event) => {
const message: NotificationMessage = JSON.parse(event.data);
handleNotification(message);
};
wsRef.current.onclose = () => {
console.log('WebSocket disconnected');
scheduleReconnect();
};
wsRef.current.onerror = (error) => {
console.error('WebSocket error:', error);
};
};
const scheduleReconnect = () => {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
reconnectAttempts.current++;
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
};
const handleNotification = (message: NotificationMessage) => {
switch (message.type) {
case 'execution.started':
case 'execution.completed':
case 'execution.failed':
// Update execution in cache
queryClient.setQueryData(
['executions', message.data.id],
message.data
);
// Invalidate execution lists
queryClient.invalidateQueries({ queryKey: ['executions'] });
break;
case 'inquiry.created':
// Add notification to UI
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
break;
case 'event.created':
// Update event stream
queryClient.invalidateQueries({ queryKey: ['events'] });
break;
}
};
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [queryClient]);
}
Usage in App:
// src/App.tsx
function App() {
useWebSocketNotifications(); // Connect once at app level
return (
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>
);
}
Workflow Visualization
React Flow
- Purpose: Visual workflow editor and execution graph display
- Why: Best-in-class workflow visualization library for React
- Features:
- Drag-and-drop node creation
- Custom node types (action, decision, parallel, inquiry)
- Edge routing and validation
- Minimap and controls
- Export to image
- Zoom and pan
Workflow Editor Example:
// src/components/WorkflowEditor.tsx
import ReactFlow, {
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
} from 'reactflow';
import 'reactflow/dist/style.css';
const nodeTypes = {
action: ActionNode,
decision: DecisionNode,
parallel: ParallelNode,
inquiry: InquiryNode,
};
export function WorkflowEditor({ workflow }: { workflow: Workflow }) {
const [nodes, setNodes, onNodesChange] = useNodesState(
convertWorkflowToNodes(workflow)
);
const [edges, setEdges, onEdgesChange] = useEdgesState(
convertWorkflowToEdges(workflow)
);
const onConnect = useCallback(
(params) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
);
return (
<div style={{ height: '600px' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
</div>
);
}
Custom Node Types:
- ActionNode: Represents an action execution with status indicator
- DecisionNode: Conditional branching with expression display
- ParallelNode: Concurrent execution branches
- InquiryNode: Human-in-the-loop interaction points
Code/YAML Editing
Monaco Editor
- Purpose: Rich code/YAML editor for workflows, rules, and configurations
- Why: Same editor as VS Code, excellent language support
- Features:
- Syntax highlighting for YAML, JSON, Python, JavaScript
- Auto-completion
- Error detection and linting
- Diff editor for comparing versions
- Themes (light/dark)
Monaco Integration:
// src/components/CodeEditor.tsx
import Editor from '@monaco-editor/react';
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
language: 'yaml' | 'json' | 'python' | 'javascript';
readOnly?: boolean;
}
export function CodeEditor({
value,
onChange,
language,
readOnly = false
}: CodeEditorProps) {
return (
<Editor
height="400px"
language={language}
value={value}
onChange={(val) => onChange(val || '')}
theme="vs-dark"
options={{
readOnly,
minimap: { enabled: false },
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
);
}
YAML Validation:
// src/utils/yamlValidation.ts
import yaml from 'js-yaml';
import Ajv from 'ajv';
export function validateWorkflowYAML(content: string): ValidationResult {
try {
const parsed = yaml.load(content);
// Validate against workflow schema
const ajv = new Ajv();
const valid = ajv.validate(workflowSchema, parsed);
if (!valid) {
return { valid: false, errors: ajv.errors };
}
return { valid: true, data: parsed };
} catch (error) {
return {
valid: false,
errors: [{ message: error.message }]
};
}
}
UI Components
shadcn/ui
- Purpose: High-quality, accessible component library
- Why: Copy-paste components (no NPM bloat), fully customizable, Tailwind-based
- Components Used:
- Button, Input, Select, Checkbox
- Table, Card, Dialog, Popover
- Tabs, Accordion, Sheet (side panel)
- Toast (notifications)
- Command (command palette)
- Form (with react-hook-form integration)
Installation: Components are copied into project, not installed as dependency
Usage Pattern:
// src/components/actions/ActionList.tsx
import { Button } from '@/components/ui/button';
import { Table } from '@/components/ui/table';
import { useActions } from '@/hooks/useActions';
export function ActionList() {
const { data, isLoading } = useActions();
if (isLoading) return <Skeleton />;
return (
<div>
<div className="flex justify-between mb-4">
<h1>Actions</h1>
<Button onClick={handleCreate}>Create Action</Button>
</div>
<Table>
{/* Table content */}
</Table>
</div>
);
}
Tailwind CSS
- Purpose: Utility-first CSS framework
- Why: Fast styling, consistent design system, small production bundle
- Configuration:
- Custom color palette matching Attune brand
- Dark mode support
- Responsive breakpoints
- Custom animations for loading states
Routing
React Router v6
- Purpose: Client-side routing and navigation
- Why: Standard React routing solution, type-safe with TypeScript
- Features:
- Nested routes
- Lazy loading for code splitting
- Protected routes
- URL parameter handling
Route Structure:
// src/router.tsx
import { createBrowserRouter } from 'react-router-dom';
export const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />,
},
{
path: '/',
element: <ProtectedRoute><Layout /></ProtectedRoute>,
children: [
{ index: true, element: <DashboardPage /> },
// Packs
{ path: 'packs', element: <PackListPage /> },
{ path: 'packs/:ref', element: <PackDetailPage /> },
// Actions
{ path: 'actions', element: <ActionListPage /> },
{ path: 'actions/:ref', element: <ActionDetailPage /> },
{ path: 'actions/:ref/edit', element: <ActionEditPage /> },
// Rules
{ path: 'rules', element: <RuleListPage /> },
{ path: 'rules/:ref', element: <RuleDetailPage /> },
// Workflows
{ path: 'workflows', element: <WorkflowListPage /> },
{ path: 'workflows/:ref', element: <WorkflowDetailPage /> },
{ path: 'workflows/:ref/edit', element: <WorkflowEditorPage /> },
// Executions
{ path: 'executions', element: <ExecutionListPage /> },
{ path: 'executions/:id', element: <ExecutionDetailPage /> },
// Events & Enforcements
{ path: 'events', element: <EventListPage /> },
{ path: 'enforcements', element: <EnforcementListPage /> },
// Inquiries
{ path: 'inquiries', element: <InquiryListPage /> },
{ path: 'inquiries/:id', element: <InquiryDetailPage /> },
// Settings
{ path: 'settings', element: <SettingsPage /> },
{ path: 'settings/secrets', element: <SecretsPage /> },
],
},
]);
State Management
Approach: Minimal global state, prefer server state (React Query) and component state
Global State (if needed):
- Zustand: Lightweight state management for truly global UI state
- Use cases:
- Theme preference (light/dark)
- Sidebar collapsed/expanded state
- User preferences
- Command palette open/closed
// src/stores/uiStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UIState {
theme: 'light' | 'dark';
sidebarCollapsed: boolean;
toggleTheme: () => void;
toggleSidebar: () => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
theme: 'dark',
sidebarCollapsed: false,
toggleTheme: () => set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light'
})),
toggleSidebar: () => set((state) => ({
sidebarCollapsed: !state.sidebarCollapsed
})),
}),
{ name: 'attune-ui-store' }
)
);
Project Structure
attune-web/
├── public/
│ ├── favicon.ico
│ └── logo.svg
├── src/
│ ├── api/ # OpenAPI generated code
│ │ ├── models/ # TypeScript interfaces for all DTOs
│ │ ├── services/ # API service classes
│ │ └── core/ # Axios client configuration
│ ├── components/
│ │ ├── ui/ # shadcn/ui base components
│ │ ├── layout/ # Layout components (Sidebar, Header, etc.)
│ │ ├── actions/ # Action-related components
│ │ ├── rules/ # Rule-related components
│ │ ├── workflows/ # Workflow editor and viewer
│ │ ├── executions/ # Execution monitoring
│ │ ├── events/ # Event stream
│ │ └── common/ # Shared components
│ ├── hooks/ # Custom React hooks
│ │ ├── useActions.ts
│ │ ├── useRules.ts
│ │ ├── useWorkflows.ts
│ │ ├── useExecutions.ts
│ │ └── useWebSocket.ts
│ ├── contexts/ # React contexts
│ │ └── AuthContext.tsx
│ ├── stores/ # Zustand stores (minimal)
│ │ └── uiStore.ts
│ ├── pages/ # Page components
│ │ ├── DashboardPage.tsx
│ │ ├── LoginPage.tsx
│ │ ├── actions/
│ │ ├── rules/
│ │ ├── workflows/
│ │ └── executions/
│ ├── websocket/ # WebSocket client
│ │ └── client.ts
│ ├── utils/ # Utility functions
│ │ ├── formatters.ts
│ │ ├── validators.ts
│ │ └── yaml.ts
│ ├── types/ # Additional TypeScript types
│ ├── router.tsx # React Router configuration
│ ├── App.tsx # Root component
│ └── main.tsx # Entry point
├── .env.development # Dev environment variables
├── .env.production # Prod environment variables
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
└── tailwind.config.js
Development Workflow
Initial Setup
# Create React + TypeScript project with Vite
npm create vite@latest attune-web -- --template react-ts
cd attune-web
# Install core dependencies
npm install react-router-dom @tanstack/react-query axios
npm install @monaco-editor/react reactflow zustand js-yaml
# Install UI dependencies
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
# Install dev dependencies
npm install -D @types/node openapi-typescript-codegen
# Initialize shadcn/ui
npx shadcn-ui@latest init
Generate API Client
# Generate TypeScript client from OpenAPI spec
# Run this whenever the API changes
npm run generate:api
package.json script:
{
"scripts": {
"generate:api": "openapi-typescript-codegen --input http://localhost:8080/api-spec/openapi.json --output ./src/api --client axios --useOptions"
}
}
Development Server
# Start dev server with HMR
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
Environment Variables
.env.development:
VITE_API_URL=http://localhost:8080
VITE_WS_URL=ws://localhost:8081
VITE_LOG_LEVEL=debug
.env.production:
VITE_API_URL=https://api.attune.example.com
VITE_WS_URL=wss://notifications.attune.example.com
VITE_LOG_LEVEL=error
Form Management and Entity Editability
Pack-Based vs UI-Configurable Components
Attune uses a pack-based architecture where most automation components are defined as code and bundled into packs. The Web UI must respect these architectural constraints when providing forms.
Code-Based Components (NOT UI-Editable)
Actions:
- Implemented as executable code (Python, Node.js, Shell)
- Registered when a pack is loaded/installed
- No create/edit forms in Web UI
- Managed through pack lifecycle (install, update, uninstall)
- Rationale: Security, performance, code quality, testing requirements
Sensors:
- Implemented as executable code with event monitoring logic
- Registered when a pack is loaded/installed
- No create/edit forms in Web UI
- Managed through pack lifecycle
- Rationale: Require event loop integration, complex dependencies, safety
Mixed Model Components
Triggers:
- Pack-based triggers: Registered with system packs (e.g.,
slack.message_received)- NOT UI-editable - defined in pack manifest
- Ad-hoc triggers: For custom integrations without code
- UI-editable via trigger form (future feature)
- Only for ad-hoc packs (
system: false) - Define parameters schema and payload schema
Always UI-Configurable Components
Rules:
- Connect triggers to actions with criteria and parameters
- No code execution, just data mapping
- Full CRUD operations via Web UI
- Users need flexibility to change business logic
Packs (Ad-Hoc):
- User-created packs for custom automation
- Full CRUD operations via Web UI
- Define configuration schema (JSON Schema format)
- No code required
Workflows (Future):
- Multi-step automation sequences
- Visual workflow editor (React Flow)
- Workflow actions (special configurable actions)
Form Implementation Guidelines
Required Forms:
- ✅ RuleForm (
/rules/new,/rules/:id/edit) - Implemented - ✅ PackForm (
/packs/new,/packs/:name/edit) - Implemented - 🔄 TriggerForm (
/triggers/new,/triggers/:id/edit) - Future- Only for ad-hoc packs
- Validate pack is non-system before allowing trigger creation
- 🔄 WorkflowForm (
/workflows/new,/workflows/:ref/edit) - Future
NOT Required:
- ❌ ActionForm - Actions are code-based
- ❌ SensorForm - Sensors are code-based
Form Validation Strategy
Client-Side Validation:
- Required field checks
- Format validation (names, versions, JSON syntax)
- JSON Schema validation for configuration schemas
- Real-time error display with field-level messages
Server-Side Validation:
- API error capture and display
- Generic error handling for network failures
- Field-specific errors when available
Form State Management
// Example: RuleForm component structure
function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
const isEditing = !!rule;
// Local form state
const [packId, setPackId] = useState(rule?.pack_id || 0);
const [triggerId, setTriggerId] = useState(rule?.trigger_id || 0);
const [actionId, setActionId] = useState(rule?.action_id || 0);
const [errors, setErrors] = useState<Record<string, string>>({});
// Data fetching
const { data: packs } = usePacks();
const { data: triggers } = usePackTriggers(selectedPackName);
const { data: actions } = usePackActions(selectedPackName);
// Mutations
const createRule = useCreateRule();
const updateRule = useUpdateRule();
// Validation and submission
const validateForm = () => { /* ... */ };
const handleSubmit = async (e) => { /* ... */ };
return <form onSubmit={handleSubmit}>...</form>;
}
Key Patterns:
- Cascading dropdowns (pack → triggers/actions)
- Immutable fields when editing (pack, trigger, action IDs)
- JSON editors with syntax validation
- Optimistic UI updates after mutations
- Auto-navigation after successful creation
Entity List Pages with Create Buttons
Pattern: List pages should have a prominent "Create" button when entity creation is allowed.
Implemented:
- ✅ Rules list page: "Create Rule" button →
/rules/new - ✅ Packs list page: "Register Pack" button →
/packs/new
Should NOT Have Create Buttons:
- ❌ Actions list page - Actions are code-based
- ❌ Sensors list page - Sensors are code-based
Future:
- 🔄 Triggers list page: "Create Trigger" button (only shows for ad-hoc packs)
- 🔄 Workflows list page: "Create Workflow" button
See docs/pack-management-architecture.md for detailed architectural rationale.
Key Features Implementation
Dashboard
- Real-time metrics: Active executions, success/failure rates, event throughput
- Recent activity: Latest executions, events, and inquiries
- Quick actions: Common tasks (run workflow, create rule)
- System health: Service status indicators
Pack Management
- List view: All packs with search and filters
- Detail view: Pack info, contained actions/rules/workflows
- Create/Edit: Form for pack metadata
- Sync workflows: Trigger workflow sync from pack directory
Action Management
- List view: Searchable, filterable table of actions
- Detail view: Action parameters, metadata, associated rules
- Create/Edit: Form with parameter schema editor
- Test runner: Execute action with test parameters
Rule Management
- List view: All rules with enable/disable toggle
- Detail view: Trigger, action, parameter mapping, criteria
- Create/Edit: Visual rule builder with expression editor
- Testing: Test rule against sample event payload
Workflow Management
- List view: Workflows with status, tags, last execution
- Visual editor: React Flow-based workflow designer
- YAML editor: Monaco editor with validation
- Dual mode: Switch between visual and code editing
- Execution history: View past executions with drill-down
Execution Monitoring
- Live dashboard: Real-time execution updates via WebSocket
- Filterable list: By status, action, pack, time range
- Detail view: Full execution context, logs, parent/child relationships
- Retry/Cancel: Actions on executions
- Log streaming: Real-time log output for running executions
Event Stream
- Live feed: Real-time event stream
- Filters: By trigger, status, time range
- Detail view: Event payload, resulting enforcements
- Replay: Re-evaluate rules against past events
Inquiry Management
- Notification center: Pending inquiries requiring action
- Response interface: Form for inquiry responses
- History: Past inquiries with responses
Performance Optimizations
- Code Splitting: Lazy load routes with
React.lazy() - Bundle Optimization: Tree shaking, minification via Vite
- Image Optimization: WebP format, lazy loading
- Query Caching: Intelligent cache times based on data volatility
- Virtual Scrolling: For large lists (executions, events)
- Debounced Search: Avoid excessive API calls
- Optimistic Updates: Immediate UI feedback on mutations
- Prefetching: Prefetch likely next pages on hover
Testing Strategy
Unit Tests
- Component logic with React Testing Library
- Hook behavior with
@testing-library/react-hooks - Utility functions with Jest
Integration Tests
- User flows (login, create workflow, monitor execution)
- API client mocking with MSW (Mock Service Worker)
- Form validation and submission
E2E Tests
- Critical paths with Playwright
- Cross-browser testing
- Accessibility testing with axe-core
Deployment
Build Output
npm run build
# Outputs to dist/ directory
Static Hosting
- Deploy
dist/to any static host (Nginx, Cloudflare Pages, Vercel, Netlify) - Configure routing: redirect all requests to
index.htmlfor SPA routing
Docker Container
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
nginx.conf:
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA routing: redirect all to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Security Considerations
- XSS Prevention: React's automatic escaping, CSP headers
- Token Storage: Access token in memory (preferred) or localStorage with caution
- API Communication: HTTPS only in production
- Input Validation: Client-side validation + server-side enforcement
- CORS: Proper CORS configuration on API service
- CSP Headers: Content Security Policy to prevent injection attacks
Accessibility
- Semantic HTML: Proper heading hierarchy, landmarks
- ARIA Labels: For interactive elements and dynamic content
- Keyboard Navigation: All features accessible via keyboard
- Focus Management: Clear focus indicators, focus trapping in modals
- Screen Reader Support: Announcements for dynamic updates
- Color Contrast: WCAG AA compliance minimum
Browser Support
- Modern Browsers: Chrome, Firefox, Safari, Edge (latest 2 versions)
- No IE Support: Modern JavaScript and CSS features used
Future Enhancements
- Progressive Web App: Offline support, install prompt
- Internationalization: i18n support with react-i18next
- Advanced Workflow Editor: Template library, drag-from-palette
- Collaboration: Multi-user editing indicators
- Telemetry: Usage analytics and error tracking
- Mobile Responsive: Optimized layouts for tablets and phones
- Command Palette: Keyboard-driven navigation (Cmd+K)
- Export/Import: Workflow and pack export in various formats
Documentation for Developers
All developers working on the web UI should familiarize themselves with:
- This architecture document
- OpenAPI specification at
/api-spec/openapi.json - React Query documentation for data fetching patterns
- shadcn/ui component documentation
- React Flow documentation for workflow editor
Troubleshooting Common Issues
API Client Out of Sync
Problem: TypeScript errors after API changes
Solution: Regenerate client with npm run generate:api
WebSocket Not Connecting
Problem: Real-time updates not working
Solution: Check VITE_WS_URL, verify notifier service is running
Authentication Loops
Problem: Constant redirects to login
Solution: Check token expiry, verify refresh token mechanism
Slow Initial Load
Problem: Large bundle size
Solution: Review code splitting, check for unnecessary dependencies in bundle
This architecture provides a solid foundation for building a professional, performant, and maintainable web interface for the Attune automation platform.