feat: Add frontend pages for Hardware Status, Process Control, Recipe Management, System Settings, and User Management

feat: Implement API service for handling system, recipe, process, hardware, safety, and user management endpoints

feat: Create Zustand store for managing system state and connection status

feat: Define TypeScript types for system, recipe, process, hardware, safety, user, and API responses

chore: Configure Vite for React development with TypeScript and Tailwind CSS

feat: Implement CSV migration tools for importing legacy data into the new system
This commit is contained in:
2025-08-06 22:36:59 +02:00
parent 196b6fff06
commit c047a1e4a2
34 changed files with 3167 additions and 23 deletions

View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

View File

@@ -0,0 +1,10 @@
# Development environment
VITE_API_BASE_URL=http://localhost:8000
VITE_WEBSOCKET_URL=ws://localhost:8000
VITE_APP_TITLE=Tempering Control System
VITE_APP_ENV=development
# Production environment (example)
# VITE_API_BASE_URL=https://your-production-domain.com
# VITE_WEBSOCKET_URL=wss://your-production-domain.com
# VITE_APP_ENV=production

View File

@@ -0,0 +1,24 @@
{
"env": {
"browser": true,
"es2020": true
},
"extends": [
"eslint:recommended",
"@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"ignorePatterns": ["dist", ".eslintrc.cjs"],
"parser": "@typescript-eslint/parser",
"plugins": ["react-refresh"],
"rules": {
"react-refresh/only-export-components": [
"warn",
{ "allowConstantExport": true }
],
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"prefer-const": "error",
"no-var": "error"
}
}

37
python_rewrite/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Dependencies
node_modules/
# Build output
dist/
build/
# Environment files
.env.local
.env.*.local
# IDE
.vscode/
.idea/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

View File

@@ -0,0 +1,261 @@
# Chocolate Tempering Machine - Frontend
A modern React TypeScript frontend for the industrial chocolate tempering machine control system. Designed for touch-friendly operation on industrial tablets with real-time monitoring capabilities.
## Features
- **Touch-Optimized Interface**: Designed for industrial tablets with large buttons and touch-friendly interactions
- **Real-time Monitoring**: WebSocket-based live updates of temperature, process status, and system health
- **Responsive Design**: Works on desktop, tablet, and mobile devices
- **Industrial Theme**: Professional interface suitable for industrial environments
- **Process Control**: Start, pause, resume, and monitor tempering processes
- **Recipe Management**: Create, edit, and manage chocolate tempering recipes
- **Hardware Monitoring**: Real-time status of temperature sensors, motors, and communication
- **Safety Alerts**: Visual and audio alerts for safety conditions and alarms
- **User Management**: Role-based access control with operator, supervisor, and admin roles
- **Data Visualization**: Temperature charts and process analytics
## Technology Stack
- **React 18** - Modern React with hooks and concurrent features
- **TypeScript** - Type-safe development
- **Vite** - Fast build tool and development server
- **Tailwind CSS** - Utility-first CSS framework for rapid styling
- **React Router** - Client-side routing
- **Zustand** - Lightweight state management
- **React Query** - Server state management and caching
- **Socket.IO Client** - Real-time WebSocket communication
- **Recharts** - Charts and data visualization
- **Lucide React** - Modern icon library
- **React Hook Form** - Form management
- **React Hot Toast** - Toast notifications
## Quick Start
### Prerequisites
- Node.js 18+ and npm
- Running backend API server (see `../` for Python backend)
### Installation
```bash
# Install dependencies
npm install
# Copy environment configuration
cp .env.example .env
# Start development server
npm run dev
```
The application will be available at `http://localhost:3000`
### Build for Production
```bash
# Build for production
npm run build
# Preview production build
npm run preview
```
## Project Structure
```
src/
├── components/ # Reusable UI components
│ ├── common/ # Generic components (buttons, modals, etc.)
│ ├── layout/ # Layout components (header, sidebar, etc.)
│ ├── process/ # Process control components
│ ├── recipe/ # Recipe management components
│ └── charts/ # Data visualization components
├── pages/ # Page components
│ ├── Dashboard.tsx # Main dashboard
│ ├── ProcessControl.tsx
│ ├── RecipeManagement.tsx
│ ├── HardwareStatus.tsx
│ ├── SystemSettings.tsx
│ └── UserManagement.tsx
├── hooks/ # Custom React hooks
│ ├── useWebSocket.ts # WebSocket connection management
│ ├── useApi.ts # API data fetching
│ └── useLocalStorage.ts
├── stores/ # Zustand state stores
│ ├── systemStore.ts # System status and configuration
│ ├── processStore.ts # Process state management
│ └── userStore.ts # User authentication and preferences
├── services/ # External service integrations
│ ├── api.ts # REST API client
│ ├── websocket.ts # WebSocket service
│ └── auth.ts # Authentication service
├── types/ # TypeScript type definitions
│ └── index.ts # All type definitions
├── utils/ # Utility functions
│ ├── formatting.ts # Data formatting helpers
│ ├── validation.ts # Form validation
│ └── constants.ts # Application constants
└── styles/ # Global styles and themes
└── index.css # Tailwind CSS and custom styles
```
## Key Components
### Dashboard
- System overview with status cards
- Real-time temperature displays
- Process monitoring
- Quick action buttons
- Hardware status indicators
### Process Control
- Recipe selection and start controls
- Live temperature monitoring with charts
- Process phase indicators
- Emergency stop functionality
- Process history and logs
### Recipe Management
- Recipe CRUD operations
- Temperature profile visualization
- Recipe validation and testing
- Import/export capabilities
### Hardware Status
- Modbus communication status
- Temperature sensor readings
- Motor status and control
- Diagnostic information
### Safety Monitoring
- Active alarm display
- Safety system status
- Alarm acknowledgment
- Emergency procedures
## Configuration
### Environment Variables
Create a `.env` file with the following variables:
```env
VITE_API_BASE_URL=http://localhost:8000
VITE_WEBSOCKET_URL=ws://localhost:8000
VITE_APP_TITLE=Tempering Control System
VITE_APP_ENV=development
```
### API Integration
The frontend communicates with the Python backend through:
- **REST API**: HTTP requests for CRUD operations
- **WebSocket**: Real-time updates for live data
- **Authentication**: JWT token-based authentication
### Touch Optimization
The interface is optimized for industrial tablets:
- Minimum touch target size of 44px
- Large, clearly labeled buttons
- High contrast colors for visibility
- Disabled text selection and zoom
- Touch-friendly gestures
## Development
### Code Style
The project uses:
- **ESLint** - Code linting
- **Prettier** - Code formatting
- **TypeScript strict mode** - Type checking
- **Tailwind CSS classes** - Consistent styling
### State Management
- **Zustand** for global application state
- **React Query** for server state caching
- **React hooks** for local component state
### Real-time Updates
WebSocket connection provides:
- Process status updates every 1-2 seconds
- Temperature readings every 5 seconds
- Immediate safety alerts
- System health monitoring
## Deployment
### Docker Deployment
The frontend can be containerized and deployed with Docker:
```dockerfile
FROM node:18-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
### Production Considerations
- Enable HTTPS for secure communication
- Configure proper CORS settings
- Set up monitoring and error tracking
- Implement proper caching strategies
- Configure WebSocket proxy in production
## Browser Support
- Chrome 90+ (recommended for industrial use)
- Firefox 88+
- Safari 14+
- Edge 90+
Chrome is recommended for industrial environments due to its excellent WebSocket support and performance on touch devices.
## Troubleshooting
### Common Issues
1. **WebSocket Connection Failed**
- Check backend server is running
- Verify WebSocket URL in environment variables
- Check firewall settings
2. **API Requests Failing**
- Verify API base URL configuration
- Check backend server health endpoint
- Review CORS settings
3. **Touch Interface Issues**
- Ensure proper viewport meta tag
- Check touch-action CSS properties
- Verify button minimum sizes
### Development Tips
- Use React DevTools for component debugging
- Enable WebSocket debugging in browser DevTools
- Use Zustand DevTools for state management debugging
- Check Network tab for API request issues
## License
This project is part of the chocolate tempering machine control system. See the main project license for details.

View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="description" content="Industrial chocolate tempering machine control system" />
<meta name="theme-color" content="#dc2626" />
<!-- Touch optimizations for industrial tablets -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Tempering Control" />
<!-- Prevent zoom and enable touch manipulation -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Chocolate Tempering Machine Control</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,45 @@
{
"name": "tempering-machine-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "React frontend for chocolate tempering machine control system",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.0",
"axios": "^1.7.0",
"recharts": "^2.12.0",
"socket.io-client": "^4.7.0",
"lucide-react": "^0.427.0",
"react-hook-form": "^7.52.0",
"react-hot-toast": "^2.4.0",
"zustand": "^4.5.0",
"date-fns": "^3.6.0",
"@tanstack/react-query": "^5.51.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.5.0"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,77 @@
import React, { useEffect } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { useWebSocket } from './hooks/useWebSocket'
import { useSystemStore } from './stores/systemStore'
// Layout components
import Layout from './components/layout/Layout'
// Page components
import Dashboard from './pages/Dashboard'
import ProcessControl from './pages/ProcessControl'
import RecipeManagement from './pages/RecipeManagement'
import HardwareStatus from './pages/HardwareStatus'
import SystemSettings from './pages/SystemSettings'
import UserManagement from './pages/UserManagement'
// Error boundary
import ErrorBoundary from './components/common/ErrorBoundary'
function App() {
const { connect, disconnect, isConnected } = useWebSocket()
const { initializeSystem, isInitialized } = useSystemStore()
useEffect(() => {
// Initialize the system on app start
initializeSystem()
// Connect to WebSocket for real-time updates
connect()
return () => {
disconnect()
}
}, [initializeSystem, connect, disconnect])
if (!isInitialized) {
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-primary-600 mx-auto mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900">
Initializing Tempering System...
</h2>
<p className="text-gray-600 mt-2">
Please wait while we connect to the hardware
</p>
</div>
</div>
)
}
return (
<ErrorBoundary>
<div className="App min-h-screen bg-gray-50">
<Layout isConnected={isConnected}>
<Routes>
{/* Default route redirects to dashboard */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* Main application routes */}
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/process" element={<ProcessControl />} />
<Route path="/recipes" element={<RecipeManagement />} />
<Route path="/hardware" element={<HardwareStatus />} />
<Route path="/settings" element={<SystemSettings />} />
<Route path="/users" element={<UserManagement />} />
{/* Catch-all route for 404s */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Layout>
</div>
</ErrorBoundary>
)
}
export default App

View File

@@ -0,0 +1,142 @@
import React, { Component, ErrorInfo, ReactNode } from 'react'
import { AlertTriangle, RefreshCw, Home } from 'lucide-react'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
error: null,
errorInfo: null,
}
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null,
}
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo)
this.setState({
error,
errorInfo,
})
// Report error to monitoring service
this.reportError(error, errorInfo)
}
reportError = (error: Error, errorInfo: ErrorInfo) => {
// Here you would typically send the error to your monitoring service
// For now, we'll just log it
console.error('Reporting error:', {
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
})
}
handleReload = () => {
window.location.reload()
}
handleGoHome = () => {
window.location.href = '/'
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-8 h-8 text-red-600" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
System Error
</h1>
<p className="text-gray-600 mb-6">
The tempering control system encountered an unexpected error.
Please try reloading the application or contact support if the problem persists.
</p>
{process.env.NODE_ENV === 'development' && this.state.error && (
<div className="bg-gray-100 rounded-md p-4 mb-6 text-left">
<h3 className="font-semibold text-sm text-gray-900 mb-2">
Error Details (Development)
</h3>
<div className="text-xs text-gray-700 font-mono">
<div className="mb-2">
<strong>Message:</strong> {this.state.error.message}
</div>
{this.state.error.stack && (
<div className="mb-2">
<strong>Stack:</strong>
<pre className="mt-1 whitespace-pre-wrap text-xs">
{this.state.error.stack}
</pre>
</div>
)}
{this.state.errorInfo?.componentStack && (
<div>
<strong>Component Stack:</strong>
<pre className="mt-1 whitespace-pre-wrap text-xs">
{this.state.errorInfo.componentStack}
</pre>
</div>
)}
</div>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={this.handleReload}
className="btn btn-primary flex items-center justify-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Reload Application
</button>
<button
onClick={this.handleGoHome}
className="btn btn-secondary flex items-center justify-center gap-2"
>
<Home className="w-4 h-4" />
Go Home
</button>
</div>
<div className="mt-6 pt-4 border-t border-gray-200">
<p className="text-sm text-gray-500">
If this problem continues, please contact technical support with the error details above.
</p>
</div>
</div>
</div>
)
}
return this.props.children
}
}
export default ErrorBoundary

View File

@@ -0,0 +1,151 @@
import React from 'react'
import { Link, useLocation } from 'react-router-dom'
import {
Wifi,
WifiOff,
AlertTriangle,
Settings,
User,
Power
} from 'lucide-react'
import { useSystemStore } from '../../stores/systemStore'
interface HeaderProps {
isConnected: boolean
}
const Header: React.FC<HeaderProps> = ({ isConnected }) => {
const location = useLocation()
const { systemStatus, systemInfo } = useSystemStore()
const getStatusColor = () => {
if (!isConnected) return 'text-red-600'
if (!systemStatus) return 'text-gray-400'
switch (systemStatus.system_status) {
case 'healthy': return 'text-green-600'
case 'warning': return 'text-yellow-600'
case 'critical': return 'text-red-600'
default: return 'text-gray-400'
}
}
const getPageTitle = () => {
switch (location.pathname) {
case '/dashboard': return 'Dashboard'
case '/process': return 'Process Control'
case '/recipes': return 'Recipe Management'
case '/hardware': return 'Hardware Status'
case '/settings': return 'System Settings'
case '/users': return 'User Management'
default: return 'Tempering Control'
}
}
return (
<header className="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Left side - Logo and title */}
<div className="flex items-center space-x-4">
<Link to="/dashboard" className="flex items-center space-x-3">
<div className="w-8 h-8 bg-primary-600 rounded flex items-center justify-center">
<span className="text-white font-bold text-sm">TC</span>
</div>
<div className="hidden sm:block">
<h1 className="text-xl font-semibold text-gray-900">
Tempering Control
</h1>
<p className="text-xs text-gray-500">
{systemInfo?.version || 'v1.0.0'}
</p>
</div>
</Link>
{/* Current page indicator */}
<div className="hidden md:flex items-center">
<span className="text-gray-400 mx-2">/</span>
<span className="text-gray-700 font-medium">
{getPageTitle()}
</span>
</div>
</div>
{/* Center - Process status (on larger screens) */}
<div className="hidden lg:flex items-center space-x-4">
{systemStatus && (
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg px-3 py-1">
<div className={`w-2 h-2 rounded-full ${
systemStatus.process_status === 'running' ? 'bg-green-500 animate-pulse' :
systemStatus.process_status === 'paused' ? 'bg-yellow-500' :
systemStatus.process_status === 'error' ? 'bg-red-500' :
'bg-gray-400'
}`} />
<span className="text-sm font-medium text-gray-700">
Process: {systemStatus.process_status}
</span>
</div>
)}
</div>
{/* Right side - Status indicators and menu */}
<div className="flex items-center space-x-3">
{/* Connection status */}
<div className={`flex items-center space-x-1 ${getStatusColor()}`}>
{isConnected ? (
<Wifi className="w-4 h-4" />
) : (
<WifiOff className="w-4 h-4" />
)}
<span className="hidden sm:inline text-sm font-medium">
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
{/* Alarm indicator */}
{systemStatus && systemStatus.active_alarms > 0 && (
<div className="flex items-center space-x-1 text-red-600">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">
{systemStatus.active_alarms}
</span>
</div>
)}
{/* Emergency stop button */}
<button
className="btn btn-danger btn-touch flex items-center space-x-1"
onClick={() => {
// Handle emergency stop
console.log('Emergency stop clicked')
}}
>
<Power className="w-4 h-4" />
<span className="hidden sm:inline">E-Stop</span>
</button>
{/* Settings menu */}
<div className="flex items-center space-x-1">
<Link
to="/settings"
className="btn btn-secondary btn-touch p-2"
title="Settings"
>
<Settings className="w-4 h-4" />
</Link>
<button
className="btn btn-secondary btn-touch p-2"
title="User Menu"
>
<User className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</header>
)
}
export default Header

View File

@@ -0,0 +1,35 @@
import React, { ReactNode } from 'react'
import Header from './Header'
import Sidebar from './Sidebar'
import StatusBar from './StatusBar'
interface LayoutProps {
children: ReactNode
isConnected: boolean
}
const Layout: React.FC<LayoutProps> = ({ children, isConnected }) => {
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Fixed header */}
<Header isConnected={isConnected} />
<div className="flex flex-1 overflow-hidden">
{/* Sidebar navigation */}
<Sidebar />
{/* Main content area */}
<main className="flex-1 overflow-auto p-4">
<div className="max-w-none">
{children}
</div>
</main>
</div>
{/* Fixed status bar at bottom */}
<StatusBar isConnected={isConnected} />
</div>
)
}
export default Layout

View File

@@ -0,0 +1,106 @@
import React from 'react'
import { Link, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
Play,
BookOpen,
HardDrive,
Settings,
Users,
} from 'lucide-react'
const Sidebar: React.FC = () => {
const location = useLocation()
const navigationItems = [
{
name: 'Dashboard',
href: '/dashboard',
icon: LayoutDashboard,
description: 'System overview'
},
{
name: 'Process Control',
href: '/process',
icon: Play,
description: 'Start and monitor tempering'
},
{
name: 'Recipes',
href: '/recipes',
icon: BookOpen,
description: 'Manage tempering recipes'
},
{
name: 'Hardware',
href: '/hardware',
icon: HardDrive,
description: 'Monitor equipment status'
},
{
name: 'Settings',
href: '/settings',
icon: Settings,
description: 'System configuration'
},
{
name: 'Users',
href: '/users',
icon: Users,
description: 'User management'
},
]
const isActive = (href: string) => location.pathname === href
return (
<nav className="bg-white shadow-sm border-r border-gray-200 w-64 flex-shrink-0">
<div className="p-4">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">
Navigation
</h2>
<ul className="space-y-2">
{navigationItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<li key={item.name}>
<Link
to={item.href}
className={`
flex items-center p-3 rounded-lg text-sm font-medium touch-optimized
transition-colors duration-150
${active
? 'bg-primary-50 text-primary-700 border border-primary-200'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
}
`}
>
<Icon
className={`
w-5 h-5 mr-3 flex-shrink-0
${active ? 'text-primary-600' : 'text-gray-400'}
`}
/>
<div className="flex flex-col">
<span>{item.name}</span>
<span className={`
text-xs mt-0.5
${active ? 'text-primary-600' : 'text-gray-500'}
`}>
{item.description}
</span>
</div>
</Link>
</li>
)
})}
</ul>
</div>
</nav>
)
}
export default Sidebar

View File

@@ -0,0 +1,73 @@
import React from 'react'
import { Clock, Thermometer, Zap } from 'lucide-react'
import { useSystemStore } from '../../stores/systemStore'
interface StatusBarProps {
isConnected: boolean
}
const StatusBar: React.FC<StatusBarProps> = ({ isConnected }) => {
const { systemStatus } = useSystemStore()
const formatUptime = (seconds: number) => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return (
<div className="bg-gray-800 text-white px-4 py-2 border-t border-gray-700">
<div className="flex items-center justify-between text-sm">
{/* Left side - System info */}
<div className="flex items-center space-x-6">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${
isConnected ? 'bg-green-500' : 'bg-red-500'
}`} />
<span className="font-medium">
{isConnected ? 'Online' : 'Offline'}
</span>
</div>
{systemStatus && (
<>
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>Uptime: {formatUptime(systemStatus.uptime_seconds)}</span>
</div>
{systemStatus.active_alarms > 0 && (
<div className="flex items-center space-x-1 text-red-400">
<Zap className="w-4 h-4" />
<span>{systemStatus.active_alarms} Alarm{systemStatus.active_alarms !== 1 ? 's' : ''}</span>
</div>
)}
</>
)}
</div>
{/* Center - Current process info */}
<div className="hidden md:flex items-center space-x-4">
{systemStatus && systemStatus.process_status !== 'idle' && (
<div className="flex items-center space-x-2">
<Thermometer className="w-4 h-4" />
<span>Process: {systemStatus.process_status}</span>
</div>
)}
</div>
{/* Right side - Timestamp */}
<div className="text-gray-400 text-xs">
{systemStatus ? (
`Last updated: ${new Date(systemStatus.last_updated).toLocaleTimeString()}`
) : (
'No system data'
)}
</div>
</div>
</div>
)
}
export default StatusBar

View File

@@ -0,0 +1,234 @@
import { useEffect, useRef, useCallback, useState } from 'react'
import { io, Socket } from 'socket.io-client'
import toast from 'react-hot-toast'
import { useSystemStore } from '../stores/systemStore'
import type { WebSocketMessage, ProcessStatus, HardwareStatus, SafetyStatus } from '../types'
interface UseWebSocketReturn {
socket: Socket | null
isConnected: boolean
connect: () => void
disconnect: () => void
emit: (event: string, data?: any) => void
}
export const useWebSocket = (): UseWebSocketReturn => {
const socketRef = useRef<Socket | null>(null)
const [isConnected, setIsConnected] = useState(false)
const reconnectTimeoutRef = useRef<NodeJS.Timeout>()
const reconnectAttempts = useRef(0)
const maxReconnectAttempts = 10
// Store actions
const { updateSystemStatus, updateConnectionStatus, setError } = useSystemStore()
// Get WebSocket URL from environment
const getSocketUrl = useCallback(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
return baseUrl.replace(/^http/, 'ws')
}, [])
// Handle reconnection with exponential backoff
const scheduleReconnect = useCallback(() => {
if (reconnectAttempts.current >= maxReconnectAttempts) {
console.error('Max reconnection attempts reached')
setError('Lost connection to server. Please refresh the page.')
return
}
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000) // Max 30 seconds
reconnectAttempts.current += 1
console.log(`Scheduling reconnection attempt ${reconnectAttempts.current} in ${delay}ms`)
reconnectTimeoutRef.current = setTimeout(() => {
connect()
}, delay)
}, [setError])
// Connect to WebSocket
const connect = useCallback(() => {
if (socketRef.current?.connected) {
console.log('WebSocket already connected')
return
}
try {
const socketUrl = getSocketUrl()
console.log('Connecting to WebSocket:', socketUrl)
const socket = io(socketUrl, {
transports: ['websocket', 'polling'],
timeout: 20000,
forceNew: true,
reconnection: false, // We handle reconnection manually
})
// Connection events
socket.on('connect', () => {
console.log('WebSocket connected')
setIsConnected(true)
updateConnectionStatus(true)
reconnectAttempts.current = 0 // Reset attempts on successful connection
// Clear any existing reconnection timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = undefined
}
toast.success('Connected to tempering system', {
duration: 2000,
position: 'top-right',
})
// Subscribe to real-time updates
socket.emit('subscribe', { topics: ['process', 'hardware', 'safety', 'system'] })
})
socket.on('disconnect', (reason) => {
console.log('WebSocket disconnected:', reason)
setIsConnected(false)
updateConnectionStatus(false)
if (reason === 'io server disconnect') {
// Server initiated disconnect, don't reconnect automatically
toast.error('Disconnected from server')
} else {
// Client-side disconnect or network issue, attempt to reconnect
scheduleReconnect()
}
})
socket.on('connect_error', (error) => {
console.error('WebSocket connection error:', error)
setIsConnected(false)
updateConnectionStatus(false)
scheduleReconnect()
})
// Message handlers
socket.on('message', (message: WebSocketMessage) => {
handleWebSocketMessage(message)
})
// Specific event handlers for better performance
socket.on('process_update', (data: ProcessStatus) => {
updateSystemStatus({
system_status: 'healthy',
process_status: data.status as any,
hardware_status: 'connected',
safety_status: 'safe',
active_alarms: data.error_count + data.warning_count,
uptime_seconds: 0,
last_updated: new Date().toISOString(),
})
})
socket.on('hardware_update', (data: HardwareStatus) => {
// Handle hardware status updates
console.log('Hardware update:', data)
})
socket.on('safety_alert', (data: SafetyStatus) => {
// Handle safety alerts
if (data.overall_status === 'alarm') {
toast.error('Safety alarm triggered!', {
duration: 0, // Keep visible until dismissed
style: {
background: '#ef4444',
color: 'white',
fontWeight: 'bold',
fontSize: '18px',
},
})
} else if (data.overall_status === 'warning') {
toast.warning('Safety warning', {
duration: 5000,
})
}
})
socket.on('system_status', (data: any) => {
updateSystemStatus(data)
})
socketRef.current = socket
} catch (error) {
console.error('Failed to create WebSocket connection:', error)
scheduleReconnect()
}
}, [getSocketUrl, updateConnectionStatus, updateSystemStatus, scheduleReconnect])
// Disconnect from WebSocket
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = undefined
}
if (socketRef.current) {
console.log('Disconnecting WebSocket')
socketRef.current.disconnect()
socketRef.current = null
}
setIsConnected(false)
updateConnectionStatus(false)
}, [updateConnectionStatus])
// Emit message to server
const emit = useCallback((event: string, data?: any) => {
if (socketRef.current?.connected) {
socketRef.current.emit(event, data)
} else {
console.warn('Cannot emit event: WebSocket not connected')
}
}, [])
// Handle incoming WebSocket messages
const handleWebSocketMessage = useCallback((message: WebSocketMessage) => {
console.log('WebSocket message:', message)
switch (message.type) {
case 'process_update':
// Handle process status updates
break
case 'hardware_update':
// Handle hardware status updates
break
case 'safety_alert':
// Handle safety alerts
if (message.data.severity === 'critical') {
toast.error(`Critical Safety Alert: ${message.data.message}`, {
duration: 0, // Keep visible
})
}
break
case 'system_status':
updateSystemStatus(message.data)
break
default:
console.log('Unknown message type:', message.type)
}
}, [updateSystemStatus])
// Cleanup on unmount
useEffect(() => {
return () => {
disconnect()
}
}, [disconnect])
return {
socket: socketRef.current,
isConnected,
connect,
disconnect,
emit,
}
}

View File

@@ -0,0 +1,186 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans;
@apply touch-manipulation;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Touch-friendly buttons for industrial tablets */
.btn-touch {
@apply min-h-[48px] min-w-[48px] touch-manipulation;
}
/* Industrial style components */
.panel {
@apply bg-white rounded-lg shadow-lg border border-gray-200;
}
.status-indicator {
@apply w-4 h-4 rounded-full;
}
.temperature-display {
@apply font-mono text-2xl font-bold;
}
/* Custom scrollbar for touch devices */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-gray-100 rounded;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-400 rounded hover:bg-gray-500;
}
}
@layer components {
/* Touch-optimized button variants */
.btn {
@apply inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50;
@apply min-h-[44px] px-4 py-2 touch-manipulation;
}
.btn-primary {
@apply bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800;
}
.btn-secondary {
@apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 active:bg-secondary-300;
}
.btn-success {
@apply bg-success-600 text-white hover:bg-success-700 active:bg-success-800;
}
.btn-warning {
@apply bg-warning-500 text-white hover:bg-warning-600 active:bg-warning-700;
}
.btn-danger {
@apply bg-danger-600 text-white hover:bg-danger-700 active:bg-danger-800;
}
.btn-lg {
@apply min-h-[56px] px-6 py-3 text-base;
}
.btn-xl {
@apply min-h-[64px] px-8 py-4 text-lg;
}
/* Card components */
.card {
@apply panel p-6;
}
.card-header {
@apply flex flex-col space-y-1.5 p-6;
}
.card-title {
@apply text-2xl font-semibold leading-none tracking-tight;
}
.card-content {
@apply p-6 pt-0;
}
/* Input components */
.input {
@apply flex h-12 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
/* Status indicators */
.status-running {
@apply status-indicator bg-success-500 animate-pulse;
}
.status-paused {
@apply status-indicator bg-warning-500;
}
.status-stopped {
@apply status-indicator bg-gray-400;
}
.status-error {
@apply status-indicator bg-danger-500 animate-bounce;
}
/* Temperature displays */
.temp-normal {
@apply temperature-display text-success-600;
}
.temp-warning {
@apply temperature-display text-warning-600;
}
.temp-critical {
@apply temperature-display text-danger-600 animate-pulse;
}
}
@layer utilities {
/* Touch optimizations */
.touch-optimized {
@apply touch-manipulation select-none;
}
/* Fade animations */
.fade-enter {
@apply opacity-0;
}
.fade-enter-active {
@apply opacity-100 transition-opacity duration-300;
}
.fade-exit {
@apply opacity-100;
}
.fade-exit-active {
@apply opacity-0 transition-opacity duration-300;
}
/* Industrial grid layout */
.industrial-grid {
@apply grid gap-4 p-4;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
/* Responsive text sizes for different screen sizes */
.text-responsive {
@apply text-sm tablet:text-base desktop:text-lg;
}
.text-responsive-lg {
@apply text-base tablet:text-lg desktop:text-xl;
}
.text-responsive-xl {
@apply text-lg tablet:text-xl desktop:text-2xl;
}
}

View File

@@ -0,0 +1,90 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import App from './App.tsx'
import './index.css'
// Create a client for React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
},
mutations: {
retry: 1,
},
},
})
// Configure toast notifications for industrial environment
const toastConfig = {
duration: 4000,
position: 'top-center' as const,
reverseOrder: false,
gutter: 8,
containerClassName: 'toast-container',
containerStyle: {
top: 20,
left: 20,
bottom: 20,
right: 20,
},
toastOptions: {
// Default options for all toasts
className: 'toast-default',
duration: 4000,
style: {
minHeight: '60px',
fontSize: '16px',
fontWeight: 'bold',
borderRadius: '8px',
padding: '16px 20px',
},
// Success toasts
success: {
style: {
background: '#22c55e',
color: 'white',
},
iconTheme: {
primary: 'white',
secondary: '#22c55e',
},
},
// Error toasts
error: {
style: {
background: '#ef4444',
color: 'white',
},
iconTheme: {
primary: 'white',
secondary: '#ef4444',
},
},
// Loading toasts
loading: {
style: {
background: '#64748b',
color: 'white',
},
},
},
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster {...toastConfig} />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,281 @@
import React from 'react'
import {
Thermometer,
Play,
Pause,
Square,
AlertTriangle,
CheckCircle,
Clock,
Gauge
} from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { api } from '../services/api'
import { useSystemStore } from '../stores/systemStore'
const Dashboard: React.FC = () => {
const { systemStatus } = useSystemStore()
// Fetch real-time process status
const { data: processStatus } = useQuery({
queryKey: ['process-status'],
queryFn: api.getProcessStatus,
refetchInterval: 2000, // Refresh every 2 seconds
})
// Fetch hardware status
const { data: hardwareStatus } = useQuery({
queryKey: ['hardware-status'],
queryFn: api.getHardwareStatus,
refetchInterval: 5000, // Refresh every 5 seconds
})
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <Play className="w-5 h-5 text-green-600" />
case 'paused':
return <Pause className="w-5 h-5 text-yellow-600" />
case 'error':
return <AlertTriangle className="w-5 h-5 text-red-600" />
default:
return <Square className="w-5 h-5 text-gray-400" />
}
}
const formatDuration = (seconds: number | null) => {
if (!seconds) return '00:00:00'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<div className="flex items-center space-x-2 text-sm text-gray-500">
<Clock className="w-4 h-4" />
<span>Last updated: {new Date().toLocaleTimeString()}</span>
</div>
</div>
{/* Status cards grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* System Status Card */}
<div className="card">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">System Status</p>
<p className="text-2xl font-bold text-gray-900">
{systemStatus?.system_status || 'Unknown'}
</p>
</div>
<div className={`p-3 rounded-full ${
systemStatus?.system_status === 'healthy' ? 'bg-green-100' :
systemStatus?.system_status === 'warning' ? 'bg-yellow-100' :
'bg-red-100'
}`}>
<CheckCircle className={`w-6 h-6 ${
systemStatus?.system_status === 'healthy' ? 'text-green-600' :
systemStatus?.system_status === 'warning' ? 'text-yellow-600' :
'text-red-600'
}`} />
</div>
</div>
</div>
{/* Process Status Card */}
<div className="card">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Process Status</p>
<p className="text-2xl font-bold text-gray-900">
{processStatus?.status || 'Idle'}
</p>
</div>
<div className="p-3 rounded-full bg-blue-100">
{getStatusIcon(processStatus?.status || 'idle')}
</div>
</div>
</div>
{/* Temperature Card */}
<div className="card">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Temperature</p>
<p className="text-2xl font-bold text-gray-900">
{processStatus?.current_temperature?.toFixed(1) || '--'}°C
</p>
{processStatus?.target_temperature && (
<p className="text-sm text-gray-500">
Target: {processStatus.target_temperature.toFixed(1)}°C
</p>
)}
</div>
<div className="p-3 rounded-full bg-orange-100">
<Thermometer className="w-6 h-6 text-orange-600" />
</div>
</div>
</div>
{/* Active Alarms Card */}
<div className="card">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Active Alarms</p>
<p className="text-2xl font-bold text-gray-900">
{systemStatus?.active_alarms || 0}
</p>
</div>
<div className={`p-3 rounded-full ${
(systemStatus?.active_alarms || 0) > 0 ? 'bg-red-100' : 'bg-green-100'
}`}>
<AlertTriangle className={`w-6 h-6 ${
(systemStatus?.active_alarms || 0) > 0 ? 'text-red-600' : 'text-green-600'
}`} />
</div>
</div>
</div>
</div>
{/* Process Information */}
{processStatus && processStatus.is_running && (
<div className="card">
<div className="card-header">
<h3 className="card-title">Current Process</h3>
</div>
<div className="card-content">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-sm font-medium text-gray-600">Recipe</p>
<p className="text-lg font-semibold text-gray-900">
{processStatus.recipe_name || 'Unknown Recipe'}
</p>
</div>
<div>
<p className="text-sm font-medium text-gray-600">Phase</p>
<p className="text-lg font-semibold text-gray-900">
{processStatus.current_phase || 'Unknown'}
</p>
</div>
<div>
<p className="text-sm font-medium text-gray-600">Duration</p>
<p className="text-lg font-semibold text-gray-900">
{formatDuration(processStatus.duration_seconds)}
</p>
</div>
</div>
{processStatus.temperature_error && Math.abs(processStatus.temperature_error) > 1 && (
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div className="flex items-center">
<AlertTriangle className="w-4 h-4 text-yellow-600 mr-2" />
<span className="text-sm text-yellow-800">
Temperature error: {processStatus.temperature_error.toFixed(1)}°C
</span>
</div>
</div>
)}
</div>
</div>
)}
{/* Hardware Status */}
{hardwareStatus && (
<div className="card">
<div className="card-header">
<h3 className="card-title">Hardware Status</h3>
</div>
<div className="card-content">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Connection Status */}
<div className="flex items-center space-x-3">
<div className={`w-3 h-3 rounded-full ${
hardwareStatus.connection_status === 'connected' ? 'bg-green-500' : 'bg-red-500'
}`} />
<div>
<p className="text-sm font-medium text-gray-600">Connection</p>
<p className="text-sm text-gray-900">{hardwareStatus.connection_status}</p>
</div>
</div>
{/* Modbus Status */}
<div className="flex items-center space-x-3">
<div className={`w-3 h-3 rounded-full ${
hardwareStatus.modbus_status === 'online' ? 'bg-green-500' : 'bg-red-500'
}`} />
<div>
<p className="text-sm font-medium text-gray-600">Modbus</p>
<p className="text-sm text-gray-900">{hardwareStatus.modbus_status}</p>
</div>
</div>
{/* Last Communication */}
<div className="flex items-center space-x-3">
<Gauge className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-600">Last Comm</p>
<p className="text-sm text-gray-900">
{new Date(hardwareStatus.last_communication).toLocaleTimeString()}
</p>
</div>
</div>
</div>
{/* Temperature Sensors */}
{hardwareStatus.temperature_sensors && hardwareStatus.temperature_sensors.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-gray-600 mb-2">Temperature Sensors</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{hardwareStatus.temperature_sensors.map((sensor) => (
<div key={sensor.id} className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">{sensor.name}</span>
<div className={`w-2 h-2 rounded-full ${
sensor.status === 'normal' ? 'bg-green-500' :
sensor.status === 'warning' ? 'bg-yellow-500' : 'bg-red-500'
}`} />
</div>
<p className="text-lg font-bold text-gray-900">
{sensor.current_temp_c.toFixed(1)}°C
</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Quick Actions */}
<div className="card">
<div className="card-header">
<h3 className="card-title">Quick Actions</h3>
</div>
<div className="card-content">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<button className="btn btn-primary btn-lg">
Start Process
</button>
<button className="btn btn-warning btn-lg">
Pause Process
</button>
<button className="btn btn-secondary btn-lg">
View Recipes
</button>
<button className="btn btn-danger btn-lg">
Emergency Stop
</button>
</div>
</div>
</div>
</div>
)
}
export default Dashboard

View File

@@ -0,0 +1,16 @@
import React from 'react'
const HardwareStatus: React.FC = () => {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold text-gray-900">Hardware Status</h1>
<div className="card">
<div className="card-content">
<p>Hardware status interface will be implemented here.</p>
</div>
</div>
</div>
)
}
export default HardwareStatus

View File

@@ -0,0 +1,16 @@
import React from 'react'
const ProcessControl: React.FC = () => {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold text-gray-900">Process Control</h1>
<div className="card">
<div className="card-content">
<p>Process control interface will be implemented here.</p>
</div>
</div>
</div>
)
}
export default ProcessControl

View File

@@ -0,0 +1,16 @@
import React from 'react'
const RecipeManagement: React.FC = () => {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold text-gray-900">Recipe Management</h1>
<div className="card">
<div className="card-content">
<p>Recipe management interface will be implemented here.</p>
</div>
</div>
</div>
)
}
export default RecipeManagement

View File

@@ -0,0 +1,16 @@
import React from 'react'
const SystemSettings: React.FC = () => {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold text-gray-900">System Settings</h1>
<div className="card">
<div className="card-content">
<p>System settings interface will be implemented here.</p>
</div>
</div>
</div>
)
}
export default SystemSettings

View File

@@ -0,0 +1,16 @@
import React from 'react'
const UserManagement: React.FC = () => {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
<div className="card">
<div className="card-content">
<p>User management interface will be implemented here.</p>
</div>
</div>
</div>
)
}
export default UserManagement

View File

@@ -0,0 +1,278 @@
import axios, { AxiosResponse, AxiosError } from 'axios'
import toast from 'react-hot-toast'
import type {
SystemInfo,
SystemStatus,
Recipe,
RecipeCreate,
RecipeUpdate,
RecipeList,
ProcessStatus,
ProcessStartRequest,
ProcessActionResponse,
HardwareStatus,
SafetyStatus,
User,
UserCreate,
UserUpdate,
ApiResponse,
ApiError,
} from '../types'
// Create axios instance with default configuration
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
timeout: 30000, // 30 seconds timeout for industrial environment
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add auth token if available
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
return response
},
(error: AxiosError) => {
const apiError: ApiError = {
message: 'An error occurred',
status: error.response?.status,
}
if (error.response?.data && typeof error.response.data === 'object') {
const errorData = error.response.data as any
apiError.message = errorData.message || errorData.detail || apiError.message
apiError.code = errorData.code
} else if (error.message) {
apiError.message = error.message
}
// Handle specific error codes
if (error.response?.status === 401) {
// Unauthorized - redirect to login
localStorage.removeItem('auth_token')
window.location.href = '/login'
return Promise.reject(apiError)
}
if (error.response?.status === 503) {
// Service unavailable
toast.error('System temporarily unavailable. Please try again.')
} else if (error.response?.status >= 500) {
// Server errors
toast.error('Server error occurred. Please contact support.')
}
return Promise.reject(apiError)
}
)
// Helper function to handle API responses
const handleResponse = <T>(response: AxiosResponse<T>): T => {
return response.data
}
// API service class
class ApiService {
// System endpoints
async getSystemInfo(): Promise<SystemInfo> {
const response = await apiClient.get('/api/v1/info')
return handleResponse(response)
}
async getSystemStatus(): Promise<SystemStatus> {
const response = await apiClient.get('/api/v1/system/status')
return handleResponse(response)
}
async getHealthCheck(): Promise<{ status: string }> {
const response = await apiClient.get('/health')
return handleResponse(response)
}
// Recipe endpoints
async getRecipes(params?: {
skip?: number
limit?: number
active_only?: boolean
search?: string
}): Promise<RecipeList> {
const response = await apiClient.get('/api/v1/recipes', { params })
return handleResponse(response)
}
async getRecipe(id: number): Promise<Recipe> {
const response = await apiClient.get(`/api/v1/recipes/${id}`)
return handleResponse(response)
}
async createRecipe(recipe: RecipeCreate): Promise<Recipe> {
const response = await apiClient.post('/api/v1/recipes', recipe)
return handleResponse(response)
}
async updateRecipe(id: number, recipe: RecipeUpdate): Promise<Recipe> {
const response = await apiClient.put(`/api/v1/recipes/${id}`, recipe)
return handleResponse(response)
}
async deleteRecipe(id: number): Promise<void> {
await apiClient.delete(`/api/v1/recipes/${id}`)
}
// Process control endpoints
async getProcessStatus(): Promise<ProcessStatus> {
const response = await apiClient.get('/api/v1/process/status')
return handleResponse(response)
}
async startProcess(request: ProcessStartRequest): Promise<ProcessActionResponse> {
const response = await apiClient.post('/api/v1/process/start', request)
return handleResponse(response)
}
async pauseProcess(): Promise<ProcessActionResponse> {
const response = await apiClient.post('/api/v1/process/pause')
return handleResponse(response)
}
async resumeProcess(): Promise<ProcessActionResponse> {
const response = await apiClient.post('/api/v1/process/resume')
return handleResponse(response)
}
async stopProcess(): Promise<ProcessActionResponse> {
const response = await apiClient.post('/api/v1/process/stop')
return handleResponse(response)
}
async emergencyStop(): Promise<ProcessActionResponse> {
const response = await apiClient.post('/api/v1/process/emergency-stop')
return handleResponse(response)
}
// Hardware endpoints
async getHardwareStatus(): Promise<HardwareStatus> {
const response = await apiClient.get('/api/v1/hardware/status')
return handleResponse(response)
}
async getTemperatures(): Promise<any[]> {
const response = await apiClient.get('/api/v1/hardware/temperatures')
return handleResponse(response)
}
async getMotorStatus(): Promise<any[]> {
const response = await apiClient.get('/api/v1/hardware/motors')
return handleResponse(response)
}
async setMotorSpeed(motorId: string, speed: number): Promise<void> {
await apiClient.post(`/api/v1/hardware/motors/${motorId}/speed`, { speed })
}
// Safety endpoints
async getSafetyStatus(): Promise<SafetyStatus> {
const response = await apiClient.get('/api/v1/safety/status')
return handleResponse(response)
}
async acknowledgeAlarm(alarmId: string): Promise<void> {
await apiClient.post(`/api/v1/safety/alarms/${alarmId}/acknowledge`)
}
async clearAlarms(): Promise<void> {
await apiClient.post('/api/v1/safety/alarms/clear')
}
// User management endpoints
async getUsers(params?: {
skip?: number
limit?: number
active_only?: boolean
}): Promise<{ users: User[]; total: number }> {
const response = await apiClient.get('/api/v1/users', { params })
return handleResponse(response)
}
async getUser(id: string): Promise<User> {
const response = await apiClient.get(`/api/v1/users/${id}`)
return handleResponse(response)
}
async createUser(user: UserCreate): Promise<User> {
const response = await apiClient.post('/api/v1/users', user)
return handleResponse(response)
}
async updateUser(id: string, user: UserUpdate): Promise<User> {
const response = await apiClient.put(`/api/v1/users/${id}`, user)
return handleResponse(response)
}
async deleteUser(id: string): Promise<void> {
await apiClient.delete(`/api/v1/users/${id}`)
}
// Authentication endpoints
async login(username: string, password: string): Promise<{ access_token: string; token_type: string; user: User }> {
const response = await apiClient.post('/api/v1/auth/login', {
username,
password,
})
const data = handleResponse(response)
// Store token
localStorage.setItem('auth_token', data.access_token)
return data
}
async logout(): Promise<void> {
localStorage.removeItem('auth_token')
await apiClient.post('/api/v1/auth/logout')
}
async refreshToken(): Promise<{ access_token: string }> {
const response = await apiClient.post('/api/v1/auth/refresh')
const data = handleResponse(response)
localStorage.setItem('auth_token', data.access_token)
return data
}
// Data export endpoints
async exportProcessData(sessionId: string, format: 'csv' | 'json' = 'csv'): Promise<Blob> {
const response = await apiClient.get(`/api/v1/data/export/${sessionId}`, {
params: { format },
responseType: 'blob',
})
return response.data
}
async getProcessMetrics(days: number = 30): Promise<any> {
const response = await apiClient.get('/api/v1/data/metrics', {
params: { days },
})
return handleResponse(response)
}
}
// Create and export API service instance
export const api = new ApiService()
// Export the axios instance for direct use if needed
export { apiClient }

View File

@@ -0,0 +1,94 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { api } from '../services/api'
import type { SystemInfo, SystemStatus } from '../types'
interface SystemState {
// System information
systemInfo: SystemInfo | null
systemStatus: SystemStatus | null
isInitialized: boolean
isConnected: boolean
// Loading states
isLoading: boolean
error: string | null
// Actions
initializeSystem: () => Promise<void>
updateSystemStatus: (status: SystemStatus) => void
updateConnectionStatus: (connected: boolean) => void
clearError: () => void
setError: (error: string) => void
}
export const useSystemStore = create<SystemState>()(
persist(
(set, get) => ({
// Initial state
systemInfo: null,
systemStatus: null,
isInitialized: false,
isConnected: false,
isLoading: false,
error: null,
// Initialize system - called on app startup
initializeSystem: async () => {
const { isInitialized } = get()
if (isInitialized) return
set({ isLoading: true, error: null })
try {
// Fetch system information
const systemInfo = await api.getSystemInfo()
// Fetch initial system status
const systemStatus = await api.getSystemStatus()
set({
systemInfo,
systemStatus,
isInitialized: true,
isLoading: false,
error: null,
})
} catch (error) {
console.error('Failed to initialize system:', error)
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to initialize system',
})
}
},
// Update system status (called from WebSocket)
updateSystemStatus: (status: SystemStatus) => {
set({ systemStatus: status })
},
// Update connection status
updateConnectionStatus: (connected: boolean) => {
set({ isConnected: connected })
},
// Clear error
clearError: () => {
set({ error: null })
},
// Set error
setError: (error: string) => {
set({ error })
},
}),
{
name: 'system-store',
partialize: (state) => ({
systemInfo: state.systemInfo,
// Don't persist connection status or loading states
}),
}
)
)

View File

@@ -0,0 +1,221 @@
// System types
export interface SystemInfo {
name: string
version: string
environment: string
api_version: string
features: {
recipe_management: boolean
process_control: boolean
hardware_monitoring: boolean
safety_monitoring: boolean
user_management: boolean
real_time_monitoring: boolean
}
endpoints: {
recipes: string
process: string
hardware: string
users: string
system: string
health: string
}
}
export interface SystemStatus {
system_status: 'healthy' | 'warning' | 'critical'
process_status: 'idle' | 'running' | 'paused' | 'error'
hardware_status: 'connected' | 'disconnected' | 'error'
safety_status: 'safe' | 'warning' | 'alarm'
active_alarms: number
uptime_seconds: number
last_updated: string
}
// Recipe types
export interface Recipe {
id: number
name: string
description?: string
chocolate_type: 'dark' | 'milk' | 'white'
target_temperature_c: number
heating_duration_min: number
cooling_duration_min: number
stirring_speed_rpm: number
total_time_min: number
is_active: boolean
created_at: string
updated_at: string
created_by?: string
}
export interface RecipeCreate {
name: string
description?: string
chocolate_type: 'dark' | 'milk' | 'white'
target_temperature_c: number
heating_duration_min: number
cooling_duration_min: number
stirring_speed_rpm: number
total_time_min: number
}
export interface RecipeUpdate extends Partial<RecipeCreate> {
is_active?: boolean
}
export interface RecipeList {
recipes: Recipe[]
total: number
skip: number
limit: number
}
// Process types
export interface ProcessStatus {
session_id: string | null
recipe_id: number | null
recipe_name: string | null
status: 'idle' | 'running' | 'paused' | 'completed' | 'error'
current_phase: 'heating' | 'cooling' | 'stirring' | 'pouring' | 'idle' | null
started_at: string | null
duration_seconds: number | null
started_by: string | null
current_temperature: number | null
target_temperature: number | null
temperature_error: number | null
error_count: number
warning_count: number
is_running: boolean
}
export interface ProcessStartRequest {
recipe_id: number
user_id?: string
}
export interface ProcessActionResponse {
success: boolean
message: string
session_id?: string
}
// Hardware types
export interface HardwareStatus {
connection_status: 'connected' | 'disconnected' | 'error'
modbus_status: 'online' | 'offline' | 'error'
temperature_sensors: TemperatureSensor[]
motors: Motor[]
last_communication: string
}
export interface TemperatureSensor {
id: string
name: string
current_temp_c: number
status: 'normal' | 'warning' | 'error'
last_reading: string
}
export interface Motor {
id: string
name: string
is_running: boolean
current_speed_rpm: number
target_speed_rpm: number
current_amps: number
status: 'normal' | 'warning' | 'error'
}
// Safety types
export interface SafetyStatus {
overall_status: 'safe' | 'warning' | 'alarm'
emergency_stop_active: boolean
temperature_alarms: AlarmInfo[]
motor_alarms: AlarmInfo[]
system_alarms: AlarmInfo[]
active_alarm_count: number
}
export interface AlarmInfo {
id: string
type: 'temperature' | 'motor' | 'system'
severity: 'low' | 'medium' | 'high' | 'critical'
message: string
timestamp: string
acknowledged: boolean
}
// User types
export interface User {
id: string
username: string
email: string
full_name: string
role: 'operator' | 'supervisor' | 'admin'
is_active: boolean
created_at: string
last_login?: string
}
export interface UserCreate {
username: string
email: string
full_name: string
password: string
role: 'operator' | 'supervisor' | 'admin'
}
export interface UserUpdate extends Partial<Omit<UserCreate, 'password'>> {
is_active?: boolean
password?: string
}
// WebSocket types
export interface WebSocketMessage {
type: 'process_update' | 'hardware_update' | 'safety_alert' | 'system_status'
data: any
timestamp: string
}
// API Response types
export interface ApiResponse<T = any> {
success: boolean
data?: T
error?: string
message?: string
}
// Chart data types
export interface TemperatureDataPoint {
timestamp: string
temperature: number
target: number
phase: string
}
export interface ProcessMetrics {
total_sessions: number
successful_sessions: number
failed_sessions: number
average_duration_min: number
temperature_efficiency: number
last_updated: string
}
// Error types
export interface ApiError {
message: string
status?: number
code?: string
}
export interface SystemError {
id: string
type: 'hardware' | 'process' | 'safety' | 'system'
severity: 'low' | 'medium' | 'high' | 'critical'
message: string
details?: string
timestamp: string
resolved: boolean
}

View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_WEBSOCKET_URL: string
readonly VITE_APP_TITLE: string
readonly VITE_APP_ENV: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,109 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
950: '#450a0a',
},
secondary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
950: '#052e16',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
950: '#451a03',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
950: '#450a0a',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
spacing: {
'18': '4.5rem',
'88': '22rem',
'128': '32rem',
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-slow': 'pulse 3s infinite',
'bounce-slow': 'bounce 2s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(100%)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
screens: {
'touch': '768px',
'tablet': '1024px',
'desktop': '1280px',
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:8000',
ws: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
resolve: {
alias: {
'@': '/src',
},
},
})