Add 'web/' from commit '81828f61cf893039b89d3cf1861555f31167c37d'
git-subtree-dir: web git-subtree-mainline:7dbb36d619git-subtree-split:81828f61cf
This commit is contained in:
14
web/.env.example
Normal file
14
web/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Environment Configuration for Pecan Experiments Application
|
||||
|
||||
# USDA Vision Camera System API Configuration
|
||||
# Default: http://vision:8000 (current working setup)
|
||||
# For localhost setup, use: http://localhost:8000
|
||||
# For remote systems, use: http://192.168.1.100:8000 (replace with actual IP)
|
||||
VITE_VISION_API_URL=http://vision:8000
|
||||
|
||||
# Supabase Configuration (if needed for production)
|
||||
# VITE_SUPABASE_URL=your_supabase_url
|
||||
# VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
|
||||
# Development Configuration
|
||||
# VITE_DEV_MODE=true
|
||||
28
web/.gitignore
vendored
Normal file
28
web/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
web scrape
|
||||
augment unfinished chat.md
|
||||
dashboard template
|
||||
3
web/.vscode/extensions.json
vendored
Normal file
3
web/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["denoland.vscode-deno"]
|
||||
}
|
||||
415
web/API Documentations/docs/AI_AGENT_VIDEO_INTEGRATION_GUIDE.md
Normal file
415
web/API Documentations/docs/AI_AGENT_VIDEO_INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# 🤖 AI Agent Video Integration Guide
|
||||
|
||||
This guide provides comprehensive step-by-step instructions for AI agents and external systems to successfully integrate with the USDA Vision Camera System's video streaming functionality.
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
The USDA Vision Camera System provides a complete video streaming API that allows AI agents to:
|
||||
- Browse and select videos from multiple cameras
|
||||
- Stream videos with seeking capabilities
|
||||
- Generate thumbnails for preview
|
||||
- Access video metadata and technical information
|
||||
|
||||
## 🔗 API Base Configuration
|
||||
|
||||
### Connection Details
|
||||
```bash
|
||||
# Default API Base URL
|
||||
API_BASE_URL="http://localhost:8000"
|
||||
|
||||
# For remote access, replace with actual server IP/hostname
|
||||
API_BASE_URL="http://192.168.1.100:8000"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
**⚠️ IMPORTANT: No authentication is currently required.**
|
||||
- All endpoints are publicly accessible
|
||||
- No API keys or tokens needed
|
||||
- CORS is enabled for web browser integration
|
||||
|
||||
## 📋 Step-by-Step Integration Workflow
|
||||
|
||||
### Step 1: Verify System Connectivity
|
||||
```bash
|
||||
# Test basic connectivity
|
||||
curl -f "${API_BASE_URL}/health" || echo "❌ System not accessible"
|
||||
|
||||
# Check system status
|
||||
curl "${API_BASE_URL}/system/status"
|
||||
```
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2025-08-05T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: List Available Videos
|
||||
```bash
|
||||
# Get all videos with metadata
|
||||
curl "${API_BASE_URL}/videos/?include_metadata=true&limit=50"
|
||||
|
||||
# Filter by specific camera
|
||||
curl "${API_BASE_URL}/videos/?camera_name=camera1&include_metadata=true"
|
||||
|
||||
# Filter by date range
|
||||
curl "${API_BASE_URL}/videos/?start_date=2025-08-04T00:00:00&end_date=2025-08-05T23:59:59"
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
{
|
||||
"videos": [
|
||||
{
|
||||
"file_id": "camera1_auto_blower_separator_20250804_143022.mp4",
|
||||
"camera_name": "camera1",
|
||||
"filename": "camera1_auto_blower_separator_20250804_143022.mp4",
|
||||
"file_size_bytes": 31457280,
|
||||
"format": "mp4",
|
||||
"status": "completed",
|
||||
"created_at": "2025-08-04T14:30:22",
|
||||
"start_time": "2025-08-04T14:30:22",
|
||||
"end_time": "2025-08-04T14:32:22",
|
||||
"machine_trigger": "blower_separator",
|
||||
"is_streamable": true,
|
||||
"needs_conversion": false,
|
||||
"metadata": {
|
||||
"duration_seconds": 120.5,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"codec": "mp4v",
|
||||
"bitrate": 5000000,
|
||||
"aspect_ratio": 1.777
|
||||
}
|
||||
}
|
||||
],
|
||||
"total_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Select and Validate Video
|
||||
```bash
|
||||
# Get detailed video information
|
||||
FILE_ID="camera1_auto_blower_separator_20250804_143022.mp4"
|
||||
curl "${API_BASE_URL}/videos/${FILE_ID}"
|
||||
|
||||
# Validate video is playable
|
||||
curl -X POST "${API_BASE_URL}/videos/${FILE_ID}/validate"
|
||||
|
||||
# Get streaming technical details
|
||||
curl "${API_BASE_URL}/videos/${FILE_ID}/info"
|
||||
```
|
||||
|
||||
### Step 4: Generate Video Thumbnail
|
||||
```bash
|
||||
# Generate thumbnail at 5 seconds, 320x240 resolution
|
||||
curl "${API_BASE_URL}/videos/${FILE_ID}/thumbnail?timestamp=5.0&width=320&height=240" \
|
||||
--output "thumbnail_${FILE_ID}.jpg"
|
||||
|
||||
# Generate multiple thumbnails for preview
|
||||
for timestamp in 1 30 60 90; do
|
||||
curl "${API_BASE_URL}/videos/${FILE_ID}/thumbnail?timestamp=${timestamp}&width=160&height=120" \
|
||||
--output "preview_${timestamp}s.jpg"
|
||||
done
|
||||
```
|
||||
|
||||
### Step 5: Stream Video Content
|
||||
```bash
|
||||
# Stream entire video
|
||||
curl "${API_BASE_URL}/videos/${FILE_ID}/stream" --output "video.mp4"
|
||||
|
||||
# Stream specific byte range (for seeking)
|
||||
curl -H "Range: bytes=0-1048575" \
|
||||
"${API_BASE_URL}/videos/${FILE_ID}/stream" \
|
||||
--output "video_chunk.mp4"
|
||||
|
||||
# Test range request support
|
||||
curl -I -H "Range: bytes=0-1023" \
|
||||
"${API_BASE_URL}/videos/${FILE_ID}/stream"
|
||||
```
|
||||
|
||||
## 🔧 Programming Language Examples
|
||||
|
||||
### Python Integration
|
||||
```python
|
||||
import requests
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
class USDAVideoClient:
|
||||
def __init__(self, base_url: str = "http://localhost:8000"):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.session = requests.Session()
|
||||
|
||||
def list_videos(self, camera_name: Optional[str] = None,
|
||||
include_metadata: bool = True, limit: int = 50) -> Dict:
|
||||
"""List available videos with optional filtering."""
|
||||
params = {
|
||||
'include_metadata': include_metadata,
|
||||
'limit': limit
|
||||
}
|
||||
if camera_name:
|
||||
params['camera_name'] = camera_name
|
||||
|
||||
response = self.session.get(f"{self.base_url}/videos/", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_video_info(self, file_id: str) -> Dict:
|
||||
"""Get detailed video information."""
|
||||
response = self.session.get(f"{self.base_url}/videos/{file_id}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_thumbnail(self, file_id: str, timestamp: float = 1.0,
|
||||
width: int = 320, height: int = 240) -> bytes:
|
||||
"""Generate and download video thumbnail."""
|
||||
params = {
|
||||
'timestamp': timestamp,
|
||||
'width': width,
|
||||
'height': height
|
||||
}
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/videos/{file_id}/thumbnail",
|
||||
params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def stream_video_range(self, file_id: str, start_byte: int,
|
||||
end_byte: int) -> bytes:
|
||||
"""Stream specific byte range of video."""
|
||||
headers = {'Range': f'bytes={start_byte}-{end_byte}'}
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/videos/{file_id}/stream",
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def validate_video(self, file_id: str) -> bool:
|
||||
"""Validate that video is accessible and playable."""
|
||||
response = self.session.post(f"{self.base_url}/videos/{file_id}/validate")
|
||||
response.raise_for_status()
|
||||
return response.json().get('is_valid', False)
|
||||
|
||||
# Usage example
|
||||
client = USDAVideoClient("http://192.168.1.100:8000")
|
||||
|
||||
# List videos from camera1
|
||||
videos = client.list_videos(camera_name="camera1")
|
||||
print(f"Found {videos['total_count']} videos")
|
||||
|
||||
# Select first video
|
||||
if videos['videos']:
|
||||
video = videos['videos'][0]
|
||||
file_id = video['file_id']
|
||||
|
||||
# Validate video
|
||||
if client.validate_video(file_id):
|
||||
print(f"✅ Video {file_id} is valid")
|
||||
|
||||
# Get thumbnail
|
||||
thumbnail = client.get_thumbnail(file_id, timestamp=5.0)
|
||||
with open(f"thumbnail_{file_id}.jpg", "wb") as f:
|
||||
f.write(thumbnail)
|
||||
|
||||
# Stream first 1MB
|
||||
chunk = client.stream_video_range(file_id, 0, 1048575)
|
||||
print(f"Downloaded {len(chunk)} bytes")
|
||||
```
|
||||
|
||||
### JavaScript/Node.js Integration
|
||||
```javascript
|
||||
class USDAVideoClient {
|
||||
constructor(baseUrl = 'http://localhost:8000') {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async listVideos(options = {}) {
|
||||
const params = new URLSearchParams({
|
||||
include_metadata: options.includeMetadata || true,
|
||||
limit: options.limit || 50
|
||||
});
|
||||
|
||||
if (options.cameraName) {
|
||||
params.append('camera_name', options.cameraName);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/videos/?${params}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getVideoInfo(fileId) {
|
||||
const response = await fetch(`${this.baseUrl}/videos/${fileId}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getThumbnail(fileId, options = {}) {
|
||||
const params = new URLSearchParams({
|
||||
timestamp: options.timestamp || 1.0,
|
||||
width: options.width || 320,
|
||||
height: options.height || 240
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/videos/${fileId}/thumbnail?${params}`
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async validateVideo(fileId) {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/videos/${fileId}/validate`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const result = await response.json();
|
||||
return result.is_valid;
|
||||
}
|
||||
|
||||
getStreamUrl(fileId) {
|
||||
return `${this.baseUrl}/videos/${fileId}/stream`;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage example
|
||||
const client = new USDAVideoClient('http://192.168.1.100:8000');
|
||||
|
||||
async function integrateWithVideos() {
|
||||
try {
|
||||
// List videos
|
||||
const videos = await client.listVideos({ cameraName: 'camera1' });
|
||||
console.log(`Found ${videos.total_count} videos`);
|
||||
|
||||
if (videos.videos.length > 0) {
|
||||
const video = videos.videos[0];
|
||||
const fileId = video.file_id;
|
||||
|
||||
// Validate video
|
||||
const isValid = await client.validateVideo(fileId);
|
||||
if (isValid) {
|
||||
console.log(`✅ Video ${fileId} is valid`);
|
||||
|
||||
// Get thumbnail
|
||||
const thumbnail = await client.getThumbnail(fileId, {
|
||||
timestamp: 5.0,
|
||||
width: 320,
|
||||
height: 240
|
||||
});
|
||||
|
||||
// Create video element for playback
|
||||
const videoElement = document.createElement('video');
|
||||
videoElement.controls = true;
|
||||
videoElement.src = client.getStreamUrl(fileId);
|
||||
document.body.appendChild(videoElement);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Integration error:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🚨 Error Handling
|
||||
|
||||
### Common HTTP Status Codes
|
||||
```bash
|
||||
# Success responses
|
||||
200 # OK - Request successful
|
||||
206 # Partial Content - Range request successful
|
||||
|
||||
# Client error responses
|
||||
400 # Bad Request - Invalid parameters
|
||||
404 # Not Found - Video file doesn't exist
|
||||
416 # Range Not Satisfiable - Invalid range request
|
||||
|
||||
# Server error responses
|
||||
500 # Internal Server Error - Failed to process video
|
||||
503 # Service Unavailable - Video module not available
|
||||
```
|
||||
|
||||
### Error Response Format
|
||||
```json
|
||||
{
|
||||
"detail": "Video camera1_recording_20250804_143022.avi not found"
|
||||
}
|
||||
```
|
||||
|
||||
### Robust Error Handling Example
|
||||
```python
|
||||
def safe_video_operation(client, file_id):
|
||||
try:
|
||||
# Validate video first
|
||||
if not client.validate_video(file_id):
|
||||
return {"error": "Video is not valid or accessible"}
|
||||
|
||||
# Get video info
|
||||
video_info = client.get_video_info(file_id)
|
||||
|
||||
# Check if streamable
|
||||
if not video_info.get('is_streamable', False):
|
||||
return {"error": "Video is not streamable"}
|
||||
|
||||
return {"success": True, "video_info": video_info}
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
return {"error": "Video not found"}
|
||||
elif e.response.status_code == 416:
|
||||
return {"error": "Invalid range request"}
|
||||
else:
|
||||
return {"error": f"HTTP error: {e.response.status_code}"}
|
||||
except requests.exceptions.ConnectionError:
|
||||
return {"error": "Cannot connect to video server"}
|
||||
except Exception as e:
|
||||
return {"error": f"Unexpected error: {str(e)}"}
|
||||
```
|
||||
|
||||
## ✅ Integration Checklist
|
||||
|
||||
### Pre-Integration
|
||||
- [ ] Verify network connectivity to USDA Vision Camera System
|
||||
- [ ] Test basic API endpoints (`/health`, `/system/status`)
|
||||
- [ ] Understand video file naming conventions
|
||||
- [ ] Plan error handling strategy
|
||||
|
||||
### Video Selection
|
||||
- [ ] Implement video listing with appropriate filters
|
||||
- [ ] Add video validation before processing
|
||||
- [ ] Handle pagination for large video collections
|
||||
- [ ] Implement caching for video metadata
|
||||
|
||||
### Video Playback
|
||||
- [ ] Test video streaming with range requests
|
||||
- [ ] Implement thumbnail generation for previews
|
||||
- [ ] Add progress tracking for video playback
|
||||
- [ ] Handle different video formats (MP4, AVI)
|
||||
|
||||
### Error Handling
|
||||
- [ ] Handle network connectivity issues
|
||||
- [ ] Manage video not found scenarios
|
||||
- [ ] Deal with invalid range requests
|
||||
- [ ] Implement retry logic for transient failures
|
||||
|
||||
### Performance
|
||||
- [ ] Use range requests for efficient seeking
|
||||
- [ ] Implement client-side caching where appropriate
|
||||
- [ ] Monitor bandwidth usage for video streaming
|
||||
- [ ] Consider thumbnail caching for better UX
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Test Integration**: Use the provided examples to test basic connectivity
|
||||
2. **Implement Error Handling**: Add robust error handling for production use
|
||||
3. **Optimize Performance**: Implement caching and efficient streaming
|
||||
4. **Monitor Usage**: Track API usage and performance metrics
|
||||
5. **Security Review**: Consider authentication if exposing externally
|
||||
|
||||
This guide provides everything needed for successful integration with the USDA Vision Camera System's video streaming functionality. The system is designed to be simple and reliable for AI agents and external systems to consume video content efficiently.
|
||||
207
web/API Documentations/docs/API_CHANGES_SUMMARY.md
Normal file
207
web/API Documentations/docs/API_CHANGES_SUMMARY.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# API Changes Summary: Camera Settings and Video Format Updates
|
||||
|
||||
## Overview
|
||||
This document tracks major API changes including camera settings enhancements and the MP4 video format update.
|
||||
|
||||
## 🎥 Latest Update: MP4 Video Format (v2.1)
|
||||
**Date**: August 2025
|
||||
|
||||
**Major Changes**:
|
||||
- **Video Format**: Changed from AVI/XVID to MP4/MPEG-4 format
|
||||
- **File Extensions**: New recordings use `.mp4` instead of `.avi`
|
||||
- **File Size**: ~40% reduction in file sizes
|
||||
- **Streaming**: Better web browser compatibility
|
||||
|
||||
**New Configuration Fields**:
|
||||
```json
|
||||
{
|
||||
"video_format": "mp4", // File format: "mp4" or "avi"
|
||||
"video_codec": "mp4v", // Video codec: "mp4v", "XVID", "MJPG"
|
||||
"video_quality": 95 // Quality: 0-100 (higher = better)
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend Impact**:
|
||||
- ✅ Better streaming performance and browser support
|
||||
- ✅ Smaller file sizes for faster transfers
|
||||
- ✅ Universal HTML5 video player compatibility
|
||||
- ✅ Backward compatible with existing AVI files
|
||||
|
||||
**Documentation**: See [MP4 Format Update Guide](MP4_FORMAT_UPDATE.md)
|
||||
|
||||
---
|
||||
|
||||
## Previous Changes: Camera Settings and Filename Handling
|
||||
|
||||
Enhanced the `POST /cameras/{camera_name}/start-recording` API endpoint to accept optional camera settings (shutter speed/exposure, gain, and fps) and ensure all filenames have datetime prefixes.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. API Models (`usda_vision_system/api/models.py`)
|
||||
- **Enhanced `StartRecordingRequest`** to include optional parameters:
|
||||
- `exposure_ms: Optional[float]` - Exposure time in milliseconds
|
||||
- `gain: Optional[float]` - Camera gain value
|
||||
- `fps: Optional[float]` - Target frames per second
|
||||
|
||||
### 2. Camera Recorder (`usda_vision_system/camera/recorder.py`)
|
||||
- **Added `update_camera_settings()` method** to dynamically update camera settings:
|
||||
- Updates exposure time using `mvsdk.CameraSetExposureTime()`
|
||||
- Updates gain using `mvsdk.CameraSetAnalogGain()`
|
||||
- Updates target FPS in camera configuration
|
||||
- Logs all setting changes
|
||||
- Returns boolean indicating success/failure
|
||||
|
||||
### 3. Camera Manager (`usda_vision_system/camera/manager.py`)
|
||||
- **Enhanced `manual_start_recording()` method** to accept new parameters:
|
||||
- Added optional `exposure_ms`, `gain`, and `fps` parameters
|
||||
- Calls `update_camera_settings()` if any settings are provided
|
||||
- **Automatic datetime prefix**: Always prepends timestamp to filename
|
||||
- If custom filename provided: `{timestamp}_{custom_filename}`
|
||||
- If no filename provided: `{camera_name}_manual_{timestamp}.avi`
|
||||
|
||||
### 4. API Server (`usda_vision_system/api/server.py`)
|
||||
- **Updated start-recording endpoint** to:
|
||||
- Pass new camera settings to camera manager
|
||||
- Handle filename response with datetime prefix
|
||||
- Maintain backward compatibility with existing requests
|
||||
|
||||
### 5. API Tests (`api-tests.http`)
|
||||
- **Added comprehensive test examples**:
|
||||
- Basic recording (existing functionality)
|
||||
- Recording with camera settings
|
||||
- Recording with settings only (no filename)
|
||||
- Different parameter combinations
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Recording (unchanged)
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"camera_name": "camera1",
|
||||
"filename": "test.avi"
|
||||
}
|
||||
```
|
||||
**Result**: File saved as `20241223_143022_test.avi`
|
||||
|
||||
### Recording with Camera Settings
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"camera_name": "camera1",
|
||||
"filename": "high_quality.avi",
|
||||
"exposure_ms": 2.0,
|
||||
"gain": 4.0,
|
||||
"fps": 5.0
|
||||
}
|
||||
```
|
||||
**Result**:
|
||||
- Camera settings updated before recording
|
||||
- File saved as `20241223_143022_high_quality.avi`
|
||||
|
||||
### Maximum FPS Recording
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"camera_name": "camera1",
|
||||
"filename": "max_speed.avi",
|
||||
"exposure_ms": 0.1,
|
||||
"gain": 1.0,
|
||||
"fps": 0
|
||||
}
|
||||
```
|
||||
**Result**:
|
||||
- Camera captures at maximum possible speed (no delay between frames)
|
||||
- Video file saved with 30 FPS metadata for proper playback
|
||||
- Actual capture rate depends on camera hardware and exposure settings
|
||||
|
||||
### Settings Only (no filename)
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"camera_name": "camera1",
|
||||
"exposure_ms": 1.5,
|
||||
"gain": 3.0,
|
||||
"fps": 7.0
|
||||
}
|
||||
```
|
||||
**Result**:
|
||||
- Camera settings updated
|
||||
- File saved as `camera1_manual_20241223_143022.avi`
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. **Backward Compatibility**
|
||||
- All existing API calls continue to work unchanged
|
||||
- New parameters are optional
|
||||
- Default behavior preserved when no settings provided
|
||||
|
||||
### 2. **Automatic Datetime Prefix**
|
||||
- **ALL filenames now have datetime prefix** regardless of what's sent
|
||||
- Format: `YYYYMMDD_HHMMSS_` (Atlanta timezone)
|
||||
- Ensures unique filenames and chronological ordering
|
||||
|
||||
### 3. **Dynamic Camera Settings**
|
||||
- Settings can be changed per recording without restarting system
|
||||
- Based on proven implementation from `old tests/camera_video_recorder.py`
|
||||
- Proper error handling and logging
|
||||
|
||||
### 4. **Maximum FPS Capture**
|
||||
- **`fps: 0`** = Capture at maximum possible speed (no delay between frames)
|
||||
- **`fps > 0`** = Capture at specified frame rate with controlled timing
|
||||
- **`fps` omitted** = Uses camera config default (usually 3.0 fps)
|
||||
- Video files saved with 30 FPS metadata when fps=0 for proper playback
|
||||
|
||||
### 5. **Parameter Validation**
|
||||
- Uses Pydantic models for automatic validation
|
||||
- Optional parameters with proper type checking
|
||||
- Descriptive field documentation
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test script to verify functionality:
|
||||
```bash
|
||||
# Start the system first
|
||||
python main.py
|
||||
|
||||
# In another terminal, run tests
|
||||
python test_api_changes.py
|
||||
```
|
||||
|
||||
The test script verifies:
|
||||
- Basic recording functionality
|
||||
- Camera settings application
|
||||
- Filename datetime prefix handling
|
||||
- API response accuracy
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Camera Settings Mapping
|
||||
- **Exposure**: Converted from milliseconds to microseconds for SDK
|
||||
- **Gain**: Converted to camera units (multiplied by 100)
|
||||
- **FPS**: Stored in camera config, used by recording loop
|
||||
|
||||
### Error Handling
|
||||
- Settings update failures are logged but don't prevent recording
|
||||
- Invalid camera names return appropriate HTTP errors
|
||||
- Camera initialization failures are handled gracefully
|
||||
|
||||
### Filename Generation
|
||||
- Uses `format_filename_timestamp()` from timezone utilities
|
||||
- Ensures Atlanta timezone consistency
|
||||
- Handles both custom and auto-generated filenames
|
||||
|
||||
## Similar to Old Implementation
|
||||
The camera settings functionality mirrors the proven approach in `old tests/camera_video_recorder.py`:
|
||||
- Same parameter names and ranges
|
||||
- Same SDK function calls
|
||||
- Same conversion factors
|
||||
- Proven to work with the camera hardware
|
||||
824
web/API Documentations/docs/API_DOCUMENTATION.md
Normal file
824
web/API Documentations/docs/API_DOCUMENTATION.md
Normal file
@@ -0,0 +1,824 @@
|
||||
# 🚀 USDA Vision Camera System - Complete API Documentation
|
||||
|
||||
This document provides comprehensive documentation for all API endpoints in the USDA Vision Camera System, including recent enhancements and new features.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [🔧 System Status & Health](#-system-status--health)
|
||||
- [📷 Camera Management](#-camera-management)
|
||||
- [🎥 Recording Control](#-recording-control)
|
||||
- [🤖 Auto-Recording Management](#-auto-recording-management)
|
||||
- [🎛️ Camera Configuration](#️-camera-configuration)
|
||||
- [📡 MQTT & Machine Status](#-mqtt--machine-status)
|
||||
- [💾 Storage & File Management](#-storage--file-management)
|
||||
- [🔄 Camera Recovery & Diagnostics](#-camera-recovery--diagnostics)
|
||||
- [📺 Live Streaming](#-live-streaming)
|
||||
- [🎬 Video Streaming & Playback](#-video-streaming--playback)
|
||||
- [🌐 WebSocket Real-time Updates](#-websocket-real-time-updates)
|
||||
|
||||
## 🔧 System Status & Health
|
||||
|
||||
### Get System Status
|
||||
```http
|
||||
GET /system/status
|
||||
```
|
||||
**Response**: `SystemStatusResponse`
|
||||
```json
|
||||
{
|
||||
"system_started": true,
|
||||
"mqtt_connected": true,
|
||||
"last_mqtt_message": "2024-01-15T10:30:00Z",
|
||||
"machines": {
|
||||
"vibratory_conveyor": {
|
||||
"name": "vibratory_conveyor",
|
||||
"state": "ON",
|
||||
"last_updated": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"camera1": {
|
||||
"name": "camera1",
|
||||
"status": "ACTIVE",
|
||||
"is_recording": false,
|
||||
"auto_recording_enabled": true
|
||||
}
|
||||
},
|
||||
"active_recordings": 0,
|
||||
"total_recordings": 15,
|
||||
"uptime_seconds": 3600.5
|
||||
}
|
||||
```
|
||||
|
||||
### Health Check
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
**Response**: Simple health status
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 📷 Camera Management
|
||||
|
||||
### Get All Cameras
|
||||
```http
|
||||
GET /cameras
|
||||
```
|
||||
**Response**: `Dict[str, CameraStatusResponse]`
|
||||
|
||||
### Get Specific Camera Status
|
||||
```http
|
||||
GET /cameras/{camera_name}/status
|
||||
```
|
||||
**Response**: `CameraStatusResponse`
|
||||
```json
|
||||
{
|
||||
"name": "camera1",
|
||||
"status": "ACTIVE",
|
||||
"is_recording": false,
|
||||
"last_checked": "2024-01-15T10:30:00Z",
|
||||
"last_error": null,
|
||||
"device_info": {
|
||||
"model": "GigE Camera",
|
||||
"serial": "12345"
|
||||
},
|
||||
"current_recording_file": null,
|
||||
"recording_start_time": null,
|
||||
"auto_recording_enabled": true,
|
||||
"auto_recording_active": false,
|
||||
"auto_recording_failure_count": 0,
|
||||
"auto_recording_last_attempt": null,
|
||||
"auto_recording_last_error": null
|
||||
}
|
||||
```
|
||||
|
||||
## 🎥 Recording Control
|
||||
|
||||
### Start Recording
|
||||
```http
|
||||
POST /cameras/{camera_name}/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"filename": "test_recording.avi",
|
||||
"exposure_ms": 2.0,
|
||||
"gain": 4.0,
|
||||
"fps": 5.0
|
||||
}
|
||||
```
|
||||
|
||||
**Request Model**: `StartRecordingRequest`
|
||||
- `filename` (optional): Custom filename (datetime prefix will be added automatically)
|
||||
- `exposure_ms` (optional): Exposure time in milliseconds
|
||||
- `gain` (optional): Camera gain value
|
||||
- `fps` (optional): Target frames per second
|
||||
|
||||
**Response**: `StartRecordingResponse`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Recording started for camera1",
|
||||
"filename": "20240115_103000_test_recording.avi"
|
||||
}
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
- ✅ **Automatic datetime prefix**: All filenames get `YYYYMMDD_HHMMSS_` prefix
|
||||
- ✅ **Dynamic camera settings**: Adjust exposure, gain, and FPS per recording
|
||||
- ✅ **Backward compatibility**: All existing API calls work unchanged
|
||||
|
||||
### Stop Recording
|
||||
```http
|
||||
POST /cameras/{camera_name}/stop-recording
|
||||
```
|
||||
**Response**: `StopRecordingResponse`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Recording stopped for camera1",
|
||||
"duration_seconds": 45.2
|
||||
}
|
||||
```
|
||||
|
||||
## 🤖 Auto-Recording Management
|
||||
|
||||
### Enable Auto-Recording for Camera
|
||||
```http
|
||||
POST /cameras/{camera_name}/auto-recording/enable
|
||||
```
|
||||
**Response**: `AutoRecordingConfigResponse`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Auto-recording enabled for camera1",
|
||||
"camera_name": "camera1",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Disable Auto-Recording for Camera
|
||||
```http
|
||||
POST /cameras/{camera_name}/auto-recording/disable
|
||||
```
|
||||
**Response**: `AutoRecordingConfigResponse`
|
||||
|
||||
### Get Auto-Recording Status
|
||||
```http
|
||||
GET /auto-recording/status
|
||||
```
|
||||
**Response**: `AutoRecordingStatusResponse`
|
||||
```json
|
||||
{
|
||||
"running": true,
|
||||
"auto_recording_enabled": true,
|
||||
"retry_queue": {},
|
||||
"enabled_cameras": ["camera1", "camera2"]
|
||||
}
|
||||
```
|
||||
|
||||
**Auto-Recording Features**:
|
||||
- 🤖 **MQTT-triggered recording**: Automatically starts/stops based on machine state
|
||||
- 🔄 **Retry logic**: Failed recordings are retried with configurable delays
|
||||
- 📊 **Per-camera control**: Enable/disable auto-recording individually
|
||||
- 📈 **Status tracking**: Monitor failure counts and last attempts
|
||||
|
||||
## 🎛️ Camera Configuration
|
||||
|
||||
### Get Camera Configuration
|
||||
```http
|
||||
GET /cameras/{camera_name}/config
|
||||
```
|
||||
**Response**: `CameraConfigResponse`
|
||||
```json
|
||||
{
|
||||
"name": "camera1",
|
||||
"machine_topic": "blower_separator",
|
||||
"storage_path": "/storage/camera1",
|
||||
"exposure_ms": 0.3,
|
||||
"gain": 4.0,
|
||||
"target_fps": 0,
|
||||
"enabled": true,
|
||||
"video_format": "mp4",
|
||||
"video_codec": "mp4v",
|
||||
"video_quality": 95,
|
||||
"auto_start_recording_enabled": true,
|
||||
"auto_recording_max_retries": 3,
|
||||
"auto_recording_retry_delay_seconds": 2,
|
||||
"contrast": 100,
|
||||
"saturation": 100,
|
||||
"gamma": 100,
|
||||
"noise_filter_enabled": false,
|
||||
"denoise_3d_enabled": false,
|
||||
"auto_white_balance": false,
|
||||
"color_temperature_preset": 0,
|
||||
"wb_red_gain": 0.94,
|
||||
"wb_green_gain": 1.0,
|
||||
"wb_blue_gain": 0.87,
|
||||
"anti_flicker_enabled": false,
|
||||
"light_frequency": 0,
|
||||
"bit_depth": 8,
|
||||
"hdr_enabled": false,
|
||||
"hdr_gain_mode": 2
|
||||
}
|
||||
```
|
||||
|
||||
### Update Camera Configuration
|
||||
```http
|
||||
PUT /cameras/{camera_name}/config
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"exposure_ms": 2.0,
|
||||
"gain": 4.0,
|
||||
"target_fps": 5.0,
|
||||
"sharpness": 130
|
||||
}
|
||||
```
|
||||
|
||||
### Apply Configuration (Restart Required)
|
||||
```http
|
||||
POST /cameras/{camera_name}/apply-config
|
||||
```
|
||||
|
||||
**Configuration Categories**:
|
||||
- ✅ **Real-time**: `exposure_ms`, `gain`, `target_fps`, `sharpness`, `contrast`, etc.
|
||||
- ⚠️ **Restart required**: `noise_filter_enabled`, `denoise_3d_enabled`, `bit_depth`, `video_format`, `video_codec`, `video_quality`
|
||||
|
||||
For detailed configuration options, see [Camera Configuration API Guide](api/CAMERA_CONFIG_API.md).
|
||||
|
||||
## 📡 MQTT & Machine Status
|
||||
|
||||
### Get All Machines
|
||||
```http
|
||||
GET /machines
|
||||
```
|
||||
**Response**: `Dict[str, MachineStatusResponse]`
|
||||
|
||||
### Get MQTT Status
|
||||
```http
|
||||
GET /mqtt/status
|
||||
```
|
||||
**Response**: `MQTTStatusResponse`
|
||||
```json
|
||||
{
|
||||
"connected": true,
|
||||
"broker_host": "192.168.1.110",
|
||||
"broker_port": 1883,
|
||||
"subscribed_topics": ["vibratory_conveyor", "blower_separator"],
|
||||
"last_message_time": "2024-01-15T10:30:00Z",
|
||||
"message_count": 1250,
|
||||
"error_count": 2,
|
||||
"uptime_seconds": 3600.5
|
||||
}
|
||||
```
|
||||
|
||||
### Get MQTT Events History
|
||||
```http
|
||||
GET /mqtt/events?limit=10
|
||||
```
|
||||
**Response**: `MQTTEventsHistoryResponse`
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"machine_name": "vibratory_conveyor",
|
||||
"topic": "vibratory_conveyor",
|
||||
"payload": "ON",
|
||||
"normalized_state": "ON",
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"message_number": 1250
|
||||
}
|
||||
],
|
||||
"total_events": 1250,
|
||||
"last_updated": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 💾 Storage & File Management
|
||||
|
||||
### Get Storage Statistics
|
||||
```http
|
||||
GET /storage/stats
|
||||
```
|
||||
**Response**: `StorageStatsResponse`
|
||||
```json
|
||||
{
|
||||
"base_path": "/storage",
|
||||
"total_files": 150,
|
||||
"total_size_bytes": 5368709120,
|
||||
"cameras": {
|
||||
"camera1": {
|
||||
"file_count": 75,
|
||||
"total_size_bytes": 2684354560
|
||||
},
|
||||
"camera2": {
|
||||
"file_count": 75,
|
||||
"total_size_bytes": 2684354560
|
||||
}
|
||||
},
|
||||
"disk_usage": {
|
||||
"total_bytes": 107374182400,
|
||||
"used_bytes": 53687091200,
|
||||
"free_bytes": 53687091200,
|
||||
"usage_percent": 50.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get File List
|
||||
```http
|
||||
POST /storage/files
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"camera_name": "camera1",
|
||||
"start_date": "2024-01-15",
|
||||
"end_date": "2024-01-16",
|
||||
"limit": 50
|
||||
}
|
||||
```
|
||||
**Response**: `FileListResponse`
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"filename": "20240115_103000_test_recording.avi",
|
||||
"camera_name": "camera1",
|
||||
"size_bytes": 52428800,
|
||||
"created_time": "2024-01-15T10:30:00Z",
|
||||
"duration_seconds": 30.5
|
||||
}
|
||||
],
|
||||
"total_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Cleanup Old Files
|
||||
```http
|
||||
POST /storage/cleanup
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"max_age_days": 30
|
||||
}
|
||||
```
|
||||
**Response**: `CleanupResponse`
|
||||
```json
|
||||
{
|
||||
"files_removed": 25,
|
||||
"bytes_freed": 1073741824,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 Camera Recovery & Diagnostics
|
||||
|
||||
### Test Camera Connection
|
||||
```http
|
||||
POST /cameras/{camera_name}/test-connection
|
||||
```
|
||||
**Response**: `CameraTestResponse`
|
||||
|
||||
### Reconnect Camera
|
||||
```http
|
||||
POST /cameras/{camera_name}/reconnect
|
||||
```
|
||||
**Response**: `CameraRecoveryResponse`
|
||||
|
||||
### Restart Camera Grab Process
|
||||
```http
|
||||
POST /cameras/{camera_name}/restart-grab
|
||||
```
|
||||
**Response**: `CameraRecoveryResponse`
|
||||
|
||||
### Reset Camera Timestamp
|
||||
```http
|
||||
POST /cameras/{camera_name}/reset-timestamp
|
||||
```
|
||||
**Response**: `CameraRecoveryResponse`
|
||||
|
||||
### Full Camera Reset
|
||||
```http
|
||||
POST /cameras/{camera_name}/full-reset
|
||||
```
|
||||
**Response**: `CameraRecoveryResponse`
|
||||
|
||||
### Reinitialize Camera
|
||||
```http
|
||||
POST /cameras/{camera_name}/reinitialize
|
||||
```
|
||||
**Response**: `CameraRecoveryResponse`
|
||||
|
||||
**Recovery Response Example**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Camera camera1 reconnected successfully",
|
||||
"camera_name": "camera1",
|
||||
"operation": "reconnect",
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 📺 Live Streaming
|
||||
|
||||
### Get Live MJPEG Stream
|
||||
```http
|
||||
GET /cameras/{camera_name}/stream
|
||||
```
|
||||
**Response**: MJPEG video stream (multipart/x-mixed-replace)
|
||||
|
||||
### Start Camera Stream
|
||||
```http
|
||||
POST /cameras/{camera_name}/start-stream
|
||||
```
|
||||
|
||||
### Stop Camera Stream
|
||||
```http
|
||||
POST /cameras/{camera_name}/stop-stream
|
||||
```
|
||||
|
||||
**Streaming Features**:
|
||||
- 📺 **MJPEG format**: Compatible with web browsers and React apps
|
||||
- 🔄 **Concurrent operation**: Stream while recording simultaneously
|
||||
- ⚡ **Low latency**: Real-time preview for monitoring
|
||||
|
||||
For detailed streaming integration, see [Streaming Guide](guides/STREAMING_GUIDE.md).
|
||||
|
||||
## 🎬 Video Streaming & Playback
|
||||
|
||||
The system includes a comprehensive video streaming module that provides YouTube-like video playback capabilities with HTTP range request support, thumbnail generation, and intelligent caching.
|
||||
|
||||
### List Videos
|
||||
```http
|
||||
GET /videos/
|
||||
```
|
||||
**Query Parameters:**
|
||||
- `camera_name` (optional): Filter by camera name
|
||||
- `start_date` (optional): Filter videos created after this date (ISO format)
|
||||
- `end_date` (optional): Filter videos created before this date (ISO format)
|
||||
- `limit` (optional): Maximum number of results (default: 50, max: 1000)
|
||||
- `include_metadata` (optional): Include video metadata (default: false)
|
||||
|
||||
**Response**: `VideoListResponse`
|
||||
```json
|
||||
{
|
||||
"videos": [
|
||||
{
|
||||
"file_id": "camera1_auto_blower_separator_20250804_143022.mp4",
|
||||
"camera_name": "camera1",
|
||||
"filename": "camera1_auto_blower_separator_20250804_143022.mp4",
|
||||
"file_size_bytes": 31457280,
|
||||
"format": "mp4",
|
||||
"status": "completed",
|
||||
"created_at": "2025-08-04T14:30:22",
|
||||
"start_time": "2025-08-04T14:30:22",
|
||||
"end_time": "2025-08-04T14:32:22",
|
||||
"machine_trigger": "blower_separator",
|
||||
"is_streamable": true,
|
||||
"needs_conversion": false,
|
||||
"metadata": {
|
||||
"duration_seconds": 120.5,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"codec": "mp4v",
|
||||
"bitrate": 5000000,
|
||||
"aspect_ratio": 1.777
|
||||
}
|
||||
}
|
||||
],
|
||||
"total_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Get Video Information
|
||||
```http
|
||||
GET /videos/{file_id}
|
||||
```
|
||||
**Response**: `VideoInfoResponse` with detailed video information including metadata.
|
||||
|
||||
### Stream Video
|
||||
```http
|
||||
GET /videos/{file_id}/stream
|
||||
```
|
||||
**Headers:**
|
||||
- `Range: bytes=0-1023` (optional): Request specific byte range for seeking
|
||||
|
||||
**Features:**
|
||||
- ✅ **HTTP Range Requests**: Enables video seeking and progressive download
|
||||
- ✅ **Partial Content**: Returns 206 status for range requests
|
||||
- ✅ **Format Conversion**: Automatic AVI to MP4 conversion for web compatibility
|
||||
- ✅ **Intelligent Caching**: Optimized performance with byte-range caching
|
||||
- ✅ **CORS Enabled**: Ready for web browser integration
|
||||
|
||||
**Response Headers:**
|
||||
- `Accept-Ranges: bytes`
|
||||
- `Content-Length: {size}`
|
||||
- `Content-Range: bytes {start}-{end}/{total}` (for range requests)
|
||||
- `Cache-Control: public, max-age=3600`
|
||||
|
||||
### Get Video Thumbnail
|
||||
```http
|
||||
GET /videos/{file_id}/thumbnail?timestamp=5.0&width=320&height=240
|
||||
```
|
||||
**Query Parameters:**
|
||||
- `timestamp` (optional): Time position in seconds (default: 1.0)
|
||||
- `width` (optional): Thumbnail width in pixels (default: 320)
|
||||
- `height` (optional): Thumbnail height in pixels (default: 240)
|
||||
|
||||
**Response**: JPEG image data with caching headers
|
||||
|
||||
### Get Streaming Information
|
||||
```http
|
||||
GET /videos/{file_id}/info
|
||||
```
|
||||
**Response**: `StreamingInfoResponse`
|
||||
```json
|
||||
{
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"file_size_bytes": 52428800,
|
||||
"content_type": "video/mp4",
|
||||
"supports_range_requests": true,
|
||||
"chunk_size_bytes": 262144
|
||||
}
|
||||
```
|
||||
|
||||
### Video Validation
|
||||
```http
|
||||
POST /videos/{file_id}/validate
|
||||
```
|
||||
**Response**: Validation status and accessibility check
|
||||
```json
|
||||
{
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"is_valid": true
|
||||
}
|
||||
```
|
||||
|
||||
### Cache Management
|
||||
```http
|
||||
POST /videos/{file_id}/cache/invalidate
|
||||
```
|
||||
**Response**: Cache invalidation status
|
||||
```json
|
||||
{
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"cache_invalidated": true
|
||||
}
|
||||
```
|
||||
|
||||
### Admin: Cache Cleanup
|
||||
```http
|
||||
POST /admin/videos/cache/cleanup?max_size_mb=100
|
||||
```
|
||||
**Response**: Cache cleanup results
|
||||
```json
|
||||
{
|
||||
"cache_cleaned": true,
|
||||
"entries_removed": 15,
|
||||
"max_size_mb": 100
|
||||
}
|
||||
```
|
||||
|
||||
**Video Streaming Features**:
|
||||
- 🎥 **Multiple Formats**: Native MP4 support with AVI conversion
|
||||
- 📱 **Web Compatible**: Direct integration with HTML5 video elements
|
||||
- ⚡ **High Performance**: Intelligent caching and adaptive chunking
|
||||
- 🖼️ **Thumbnail Generation**: Extract preview images at any timestamp
|
||||
- 🔄 **Range Requests**: Efficient seeking and progressive download
|
||||
|
||||
## 🌐 WebSocket Real-time Updates
|
||||
|
||||
### Connect to WebSocket
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
console.log('Real-time update:', update);
|
||||
};
|
||||
```
|
||||
|
||||
**WebSocket Message Types**:
|
||||
- `system_status`: System status changes
|
||||
- `camera_status`: Camera status updates
|
||||
- `recording_started`: Recording start events
|
||||
- `recording_stopped`: Recording stop events
|
||||
- `mqtt_message`: MQTT message received
|
||||
- `auto_recording_event`: Auto-recording status changes
|
||||
|
||||
**Example WebSocket Message**:
|
||||
```json
|
||||
{
|
||||
"type": "recording_started",
|
||||
"data": {
|
||||
"camera_name": "camera1",
|
||||
"filename": "20240115_103000_auto_recording.avi",
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 Quick Start Examples
|
||||
|
||||
### Basic System Monitoring
|
||||
```bash
|
||||
# Check system health
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Get overall system status
|
||||
curl http://localhost:8000/system/status
|
||||
|
||||
# Get all camera statuses
|
||||
curl http://localhost:8000/cameras
|
||||
```
|
||||
|
||||
### Manual Recording Control
|
||||
```bash
|
||||
# Start recording with default settings
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"filename": "manual_test.avi"}'
|
||||
|
||||
# Start recording with custom camera settings
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"filename": "high_quality.avi",
|
||||
"exposure_ms": 2.0,
|
||||
"gain": 4.0,
|
||||
"fps": 5.0
|
||||
}'
|
||||
|
||||
# Stop recording
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
```
|
||||
|
||||
### Auto-Recording Management
|
||||
```bash
|
||||
# Enable auto-recording for camera1
|
||||
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/enable
|
||||
|
||||
# Check auto-recording status
|
||||
curl http://localhost:8000/auto-recording/status
|
||||
|
||||
# Disable auto-recording for camera1
|
||||
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/disable
|
||||
```
|
||||
|
||||
### Video Streaming Operations
|
||||
```bash
|
||||
# List all videos
|
||||
curl http://localhost:8000/videos/
|
||||
|
||||
# List videos from specific camera with metadata
|
||||
curl "http://localhost:8000/videos/?camera_name=camera1&include_metadata=true"
|
||||
|
||||
# Get video information
|
||||
curl http://localhost:8000/videos/camera1_recording_20250804_143022.avi
|
||||
|
||||
# Get video thumbnail
|
||||
curl "http://localhost:8000/videos/camera1_recording_20250804_143022.avi/thumbnail?timestamp=5.0&width=320&height=240" \
|
||||
--output thumbnail.jpg
|
||||
|
||||
# Get streaming info
|
||||
curl http://localhost:8000/videos/camera1_recording_20250804_143022.avi/info
|
||||
|
||||
# Stream video with range request
|
||||
curl -H "Range: bytes=0-1023" \
|
||||
http://localhost:8000/videos/camera1_recording_20250804_143022.avi/stream
|
||||
|
||||
# Validate video file
|
||||
curl -X POST http://localhost:8000/videos/camera1_recording_20250804_143022.avi/validate
|
||||
|
||||
# Clean up video cache (admin)
|
||||
curl -X POST "http://localhost:8000/admin/videos/cache/cleanup?max_size_mb=100"
|
||||
```
|
||||
|
||||
### Camera Configuration
|
||||
```bash
|
||||
# Get current camera configuration
|
||||
curl http://localhost:8000/cameras/camera1/config
|
||||
|
||||
# Update camera settings (real-time)
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"exposure_ms": 1.5,
|
||||
"gain": 3.0,
|
||||
"sharpness": 130,
|
||||
"contrast": 120
|
||||
}'
|
||||
```
|
||||
|
||||
## 📈 Recent API Changes & Enhancements
|
||||
|
||||
### ✨ New in Latest Version
|
||||
|
||||
#### 1. Enhanced Recording API
|
||||
- **Dynamic camera settings**: Set exposure, gain, and FPS per recording
|
||||
- **Automatic datetime prefixes**: All filenames get timestamp prefixes
|
||||
- **Backward compatibility**: Existing API calls work unchanged
|
||||
|
||||
#### 2. Auto-Recording Feature
|
||||
- **Per-camera control**: Enable/disable auto-recording individually
|
||||
- **MQTT integration**: Automatic recording based on machine states
|
||||
- **Retry logic**: Failed recordings are automatically retried
|
||||
- **Status tracking**: Monitor auto-recording attempts and failures
|
||||
|
||||
#### 3. Advanced Camera Configuration
|
||||
- **Real-time settings**: Update exposure, gain, image quality without restart
|
||||
- **Image enhancement**: Sharpness, contrast, saturation, gamma controls
|
||||
- **Noise reduction**: Configurable noise filtering and 3D denoising
|
||||
- **HDR support**: High Dynamic Range imaging capabilities
|
||||
|
||||
#### 4. Live Streaming
|
||||
- **MJPEG streaming**: Real-time camera preview
|
||||
- **Concurrent operation**: Stream while recording simultaneously
|
||||
- **Web-compatible**: Direct integration with React/HTML video elements
|
||||
|
||||
#### 5. Enhanced Monitoring
|
||||
- **MQTT event history**: Track machine state changes over time
|
||||
- **Storage statistics**: Monitor disk usage and file counts
|
||||
- **WebSocket updates**: Real-time system status notifications
|
||||
|
||||
#### 6. Video Streaming Module
|
||||
- **HTTP Range Requests**: Efficient video seeking and progressive download
|
||||
- **Thumbnail Generation**: Extract preview images from videos at any timestamp
|
||||
- **Format Conversion**: Automatic AVI to MP4 conversion for web compatibility
|
||||
- **Intelligent Caching**: Byte-range caching for optimal streaming performance
|
||||
- **Admin Tools**: Cache management and video validation endpoints
|
||||
|
||||
### 🔄 Migration Notes
|
||||
|
||||
#### From Previous Versions
|
||||
1. **Recording API**: All existing calls work, but now return filenames with datetime prefixes
|
||||
2. **Configuration**: New camera settings are optional and backward compatible
|
||||
3. **Auto-recording**: New feature, requires enabling in `config.json` and per camera
|
||||
|
||||
#### Configuration Updates
|
||||
```json
|
||||
{
|
||||
"cameras": [
|
||||
{
|
||||
"name": "camera1",
|
||||
"auto_start_recording_enabled": true, // NEW: Enable auto-recording
|
||||
"sharpness": 120, // NEW: Image quality settings
|
||||
"contrast": 110,
|
||||
"saturation": 100,
|
||||
"gamma": 100,
|
||||
"noise_filter_enabled": true,
|
||||
"hdr_enabled": false
|
||||
}
|
||||
],
|
||||
"system": {
|
||||
"auto_recording_enabled": true // NEW: Global auto-recording toggle
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- [📷 Camera Configuration API Guide](api/CAMERA_CONFIG_API.md) - Detailed camera settings
|
||||
- [🤖 Auto-Recording Feature Guide](features/AUTO_RECORDING_FEATURE_GUIDE.md) - React integration
|
||||
- [📺 Streaming Guide](guides/STREAMING_GUIDE.md) - Live video streaming
|
||||
- [🎬 Video Streaming Guide](VIDEO_STREAMING.md) - Video playback and streaming
|
||||
- [🤖 AI Agent Video Integration Guide](AI_AGENT_VIDEO_INTEGRATION_GUIDE.md) - Complete integration guide for AI agents
|
||||
- [🔧 Camera Recovery Guide](guides/CAMERA_RECOVERY_GUIDE.md) - Troubleshooting
|
||||
- [📡 MQTT Logging Guide](guides/MQTT_LOGGING_GUIDE.md) - MQTT configuration
|
||||
|
||||
## 📞 Support & Integration
|
||||
|
||||
### API Base URL
|
||||
- **Development**: `http://localhost:8000`
|
||||
- **Production**: Configure in `config.json` under `system.api_host` and `system.api_port`
|
||||
|
||||
### Error Handling
|
||||
All endpoints return standard HTTP status codes:
|
||||
- `200`: Success
|
||||
- `206`: Partial Content (for video range requests)
|
||||
- `400`: Bad Request (invalid parameters)
|
||||
- `404`: Resource not found (camera, file, video, etc.)
|
||||
- `416`: Range Not Satisfiable (invalid video range request)
|
||||
- `500`: Internal server error
|
||||
- `503`: Service unavailable (camera manager, MQTT, etc.)
|
||||
|
||||
**Video Streaming Specific Errors:**
|
||||
- `404`: Video file not found or not streamable
|
||||
- `416`: Invalid range request (malformed Range header)
|
||||
- `500`: Failed to read video data or generate thumbnail
|
||||
|
||||
### Rate Limiting
|
||||
- No rate limiting currently implemented
|
||||
- WebSocket connections are limited to reasonable concurrent connections
|
||||
|
||||
### CORS Support
|
||||
- CORS is enabled for web dashboard integration
|
||||
- Configure allowed origins in the API server settings
|
||||
```
|
||||
```
|
||||
195
web/API Documentations/docs/API_QUICK_REFERENCE.md
Normal file
195
web/API Documentations/docs/API_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 🚀 USDA Vision Camera System - API Quick Reference
|
||||
|
||||
Quick reference for the most commonly used API endpoints. For complete documentation, see [API_DOCUMENTATION.md](API_DOCUMENTATION.md).
|
||||
|
||||
## 🔧 System Status
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# System overview
|
||||
curl http://localhost:8000/system/status
|
||||
|
||||
# All cameras
|
||||
curl http://localhost:8000/cameras
|
||||
|
||||
# All machines
|
||||
curl http://localhost:8000/machines
|
||||
```
|
||||
|
||||
## 🎥 Recording Control
|
||||
|
||||
### Start Recording (Basic)
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"filename": "test.avi"}'
|
||||
```
|
||||
|
||||
### Start Recording (With Settings)
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"filename": "high_quality.avi",
|
||||
"exposure_ms": 2.0,
|
||||
"gain": 4.0,
|
||||
"fps": 5.0
|
||||
}'
|
||||
```
|
||||
|
||||
### Stop Recording
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
```
|
||||
|
||||
## 🤖 Auto-Recording
|
||||
|
||||
```bash
|
||||
# Enable auto-recording
|
||||
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/enable
|
||||
|
||||
# Disable auto-recording
|
||||
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/disable
|
||||
|
||||
# Check auto-recording status
|
||||
curl http://localhost:8000/auto-recording/status
|
||||
```
|
||||
|
||||
## 🎛️ Camera Configuration
|
||||
|
||||
```bash
|
||||
# Get camera config
|
||||
curl http://localhost:8000/cameras/camera1/config
|
||||
|
||||
# Update camera settings
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"exposure_ms": 1.5,
|
||||
"gain": 3.0,
|
||||
"sharpness": 130
|
||||
}'
|
||||
```
|
||||
|
||||
## 📺 Live Streaming
|
||||
|
||||
```bash
|
||||
# Start streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-stream
|
||||
|
||||
# Get MJPEG stream (use in browser/video element)
|
||||
# http://localhost:8000/cameras/camera1/stream
|
||||
|
||||
# Stop streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-stream
|
||||
```
|
||||
|
||||
## 🔄 Camera Recovery
|
||||
|
||||
```bash
|
||||
# Test connection
|
||||
curl -X POST http://localhost:8000/cameras/camera1/test-connection
|
||||
|
||||
# Reconnect camera
|
||||
curl -X POST http://localhost:8000/cameras/camera1/reconnect
|
||||
|
||||
# Full reset
|
||||
curl -X POST http://localhost:8000/cameras/camera1/full-reset
|
||||
```
|
||||
|
||||
## 💾 Storage Management
|
||||
|
||||
```bash
|
||||
# Storage statistics
|
||||
curl http://localhost:8000/storage/stats
|
||||
|
||||
# List files
|
||||
curl -X POST http://localhost:8000/storage/files \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"camera_name": "camera1", "limit": 10}'
|
||||
|
||||
# Cleanup old files
|
||||
curl -X POST http://localhost:8000/storage/cleanup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"max_age_days": 30}'
|
||||
```
|
||||
|
||||
## 📡 MQTT Monitoring
|
||||
|
||||
```bash
|
||||
# MQTT status
|
||||
curl http://localhost:8000/mqtt/status
|
||||
|
||||
# Recent MQTT events
|
||||
curl http://localhost:8000/mqtt/events?limit=10
|
||||
```
|
||||
|
||||
## 🌐 WebSocket Connection
|
||||
|
||||
```javascript
|
||||
// Connect to real-time updates
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
console.log('Update:', update);
|
||||
};
|
||||
```
|
||||
|
||||
## 📊 Response Examples
|
||||
|
||||
### System Status Response
|
||||
```json
|
||||
{
|
||||
"system_started": true,
|
||||
"mqtt_connected": true,
|
||||
"cameras": {
|
||||
"camera1": {
|
||||
"name": "camera1",
|
||||
"status": "ACTIVE",
|
||||
"is_recording": false,
|
||||
"auto_recording_enabled": true
|
||||
}
|
||||
},
|
||||
"active_recordings": 0,
|
||||
"total_recordings": 15
|
||||
}
|
||||
```
|
||||
|
||||
### Recording Start Response
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Recording started for camera1",
|
||||
"filename": "20240115_103000_test.avi"
|
||||
}
|
||||
```
|
||||
|
||||
### Camera Status Response
|
||||
```json
|
||||
{
|
||||
"name": "camera1",
|
||||
"status": "ACTIVE",
|
||||
"is_recording": false,
|
||||
"auto_recording_enabled": true,
|
||||
"auto_recording_active": false,
|
||||
"auto_recording_failure_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- [📚 Complete API Documentation](API_DOCUMENTATION.md)
|
||||
- [🎛️ Camera Configuration Guide](api/CAMERA_CONFIG_API.md)
|
||||
- [🤖 Auto-Recording Feature Guide](features/AUTO_RECORDING_FEATURE_GUIDE.md)
|
||||
- [📺 Streaming Guide](guides/STREAMING_GUIDE.md)
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
- All filenames automatically get datetime prefixes: `YYYYMMDD_HHMMSS_`
|
||||
- Camera settings can be updated in real-time during recording
|
||||
- Auto-recording is controlled per camera and globally
|
||||
- WebSocket provides real-time updates for dashboard integration
|
||||
- CORS is enabled for web application integration
|
||||
217
web/API Documentations/docs/CURRENT_CONFIGURATION.md
Normal file
217
web/API Documentations/docs/CURRENT_CONFIGURATION.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# 📋 Current System Configuration Reference
|
||||
|
||||
## Overview
|
||||
This document shows the exact current configuration structure of the USDA Vision Camera System, including all fields and their current values.
|
||||
|
||||
## 🔧 Complete Configuration Structure
|
||||
|
||||
### System Configuration (`config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mqtt": {
|
||||
"broker_host": "192.168.1.110",
|
||||
"broker_port": 1883,
|
||||
"username": null,
|
||||
"password": null,
|
||||
"topics": {
|
||||
"vibratory_conveyor": "vision/vibratory_conveyor/state",
|
||||
"blower_separator": "vision/blower_separator/state"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"base_path": "/storage",
|
||||
"max_file_size_mb": 1000,
|
||||
"max_recording_duration_minutes": 60,
|
||||
"cleanup_older_than_days": 30
|
||||
},
|
||||
"system": {
|
||||
"camera_check_interval_seconds": 2,
|
||||
"log_level": "DEBUG",
|
||||
"log_file": "usda_vision_system.log",
|
||||
"api_host": "0.0.0.0",
|
||||
"api_port": 8000,
|
||||
"enable_api": true,
|
||||
"timezone": "America/New_York",
|
||||
"auto_recording_enabled": true
|
||||
},
|
||||
"cameras": [
|
||||
{
|
||||
"name": "camera1",
|
||||
"machine_topic": "blower_separator",
|
||||
"storage_path": "/storage/camera1",
|
||||
"exposure_ms": 0.3,
|
||||
"gain": 4.0,
|
||||
"target_fps": 0,
|
||||
"enabled": true,
|
||||
"video_format": "mp4",
|
||||
"video_codec": "mp4v",
|
||||
"video_quality": 95,
|
||||
"auto_start_recording_enabled": true,
|
||||
"auto_recording_max_retries": 3,
|
||||
"auto_recording_retry_delay_seconds": 2,
|
||||
"sharpness": 0,
|
||||
"contrast": 100,
|
||||
"saturation": 100,
|
||||
"gamma": 100,
|
||||
"noise_filter_enabled": false,
|
||||
"denoise_3d_enabled": false,
|
||||
"auto_white_balance": false,
|
||||
"color_temperature_preset": 0,
|
||||
"wb_red_gain": 0.94,
|
||||
"wb_green_gain": 1.0,
|
||||
"wb_blue_gain": 0.87,
|
||||
"anti_flicker_enabled": false,
|
||||
"light_frequency": 0,
|
||||
"bit_depth": 8,
|
||||
"hdr_enabled": false,
|
||||
"hdr_gain_mode": 2
|
||||
},
|
||||
{
|
||||
"name": "camera2",
|
||||
"machine_topic": "vibratory_conveyor",
|
||||
"storage_path": "/storage/camera2",
|
||||
"exposure_ms": 0.2,
|
||||
"gain": 2.0,
|
||||
"target_fps": 0,
|
||||
"enabled": true,
|
||||
"video_format": "mp4",
|
||||
"video_codec": "mp4v",
|
||||
"video_quality": 95,
|
||||
"auto_start_recording_enabled": true,
|
||||
"auto_recording_max_retries": 3,
|
||||
"auto_recording_retry_delay_seconds": 2,
|
||||
"sharpness": 0,
|
||||
"contrast": 100,
|
||||
"saturation": 100,
|
||||
"gamma": 100,
|
||||
"noise_filter_enabled": false,
|
||||
"denoise_3d_enabled": false,
|
||||
"auto_white_balance": false,
|
||||
"color_temperature_preset": 0,
|
||||
"wb_red_gain": 1.01,
|
||||
"wb_green_gain": 1.0,
|
||||
"wb_blue_gain": 0.87,
|
||||
"anti_flicker_enabled": false,
|
||||
"light_frequency": 0,
|
||||
"bit_depth": 8,
|
||||
"hdr_enabled": false,
|
||||
"hdr_gain_mode": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Configuration Field Reference
|
||||
|
||||
### MQTT Settings
|
||||
| Field | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `broker_host` | `"192.168.1.110"` | MQTT broker IP address |
|
||||
| `broker_port` | `1883` | MQTT broker port |
|
||||
| `username` | `null` | MQTT authentication (not used) |
|
||||
| `password` | `null` | MQTT authentication (not used) |
|
||||
|
||||
### MQTT Topics
|
||||
| Machine | Topic | Camera |
|
||||
|---------|-------|--------|
|
||||
| Vibratory Conveyor | `vision/vibratory_conveyor/state` | camera2 |
|
||||
| Blower Separator | `vision/blower_separator/state` | camera1 |
|
||||
|
||||
### Storage Settings
|
||||
| Field | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `base_path` | `"/storage"` | Root storage directory |
|
||||
| `max_file_size_mb` | `1000` | Maximum file size (1GB) |
|
||||
| `max_recording_duration_minutes` | `60` | Maximum recording duration |
|
||||
| `cleanup_older_than_days` | `30` | Auto-cleanup threshold |
|
||||
|
||||
### System Settings
|
||||
| Field | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `camera_check_interval_seconds` | `2` | Camera health check interval |
|
||||
| `log_level` | `"DEBUG"` | Logging verbosity |
|
||||
| `api_host` | `"0.0.0.0"` | API server bind address |
|
||||
| `api_port` | `8000` | API server port |
|
||||
| `timezone` | `"America/New_York"` | System timezone |
|
||||
| `auto_recording_enabled` | `true` | Enable MQTT-triggered recording |
|
||||
|
||||
## 🎥 Camera Configuration Details
|
||||
|
||||
### Camera 1 (Blower Separator)
|
||||
| Setting | Value | Description |
|
||||
|---------|-------|-------------|
|
||||
| **Basic Settings** | | |
|
||||
| `name` | `"camera1"` | Camera identifier |
|
||||
| `machine_topic` | `"blower_separator"` | MQTT topic to monitor |
|
||||
| `storage_path` | `"/storage/camera1"` | Video storage location |
|
||||
| `exposure_ms` | `0.3` | Exposure time (milliseconds) |
|
||||
| `gain` | `4.0` | Camera gain multiplier |
|
||||
| `target_fps` | `0` | Target FPS (0 = unlimited) |
|
||||
| **Video Recording** | | |
|
||||
| `video_format` | `"mp4"` | Video file format |
|
||||
| `video_codec` | `"mp4v"` | Video codec (MPEG-4) |
|
||||
| `video_quality` | `95` | Video quality (0-100) |
|
||||
| **Auto Recording** | | |
|
||||
| `auto_start_recording_enabled` | `true` | Enable auto-recording |
|
||||
| `auto_recording_max_retries` | `3` | Max retry attempts |
|
||||
| `auto_recording_retry_delay_seconds` | `2` | Delay between retries |
|
||||
| **Image Quality** | | |
|
||||
| `sharpness` | `0` | Sharpness adjustment |
|
||||
| `contrast` | `100` | Contrast level |
|
||||
| `saturation` | `100` | Color saturation |
|
||||
| `gamma` | `100` | Gamma correction |
|
||||
| **White Balance** | | |
|
||||
| `auto_white_balance` | `false` | Auto white balance disabled |
|
||||
| `wb_red_gain` | `0.94` | Red channel gain |
|
||||
| `wb_green_gain` | `1.0` | Green channel gain |
|
||||
| `wb_blue_gain` | `0.87` | Blue channel gain |
|
||||
| **Advanced** | | |
|
||||
| `bit_depth` | `8` | Color bit depth |
|
||||
| `hdr_enabled` | `false` | HDR disabled |
|
||||
| `hdr_gain_mode` | `2` | HDR gain mode |
|
||||
|
||||
### Camera 2 (Vibratory Conveyor)
|
||||
| Setting | Value | Difference from Camera 1 |
|
||||
|---------|-------|--------------------------|
|
||||
| `name` | `"camera2"` | Different identifier |
|
||||
| `machine_topic` | `"vibratory_conveyor"` | Different MQTT topic |
|
||||
| `storage_path` | `"/storage/camera2"` | Different storage path |
|
||||
| `exposure_ms` | `0.2` | Faster exposure (0.2 vs 0.3) |
|
||||
| `gain` | `2.0` | Lower gain (2.0 vs 4.0) |
|
||||
| `wb_red_gain` | `1.01` | Different red balance (1.01 vs 0.94) |
|
||||
| `hdr_gain_mode` | `0` | Different HDR mode (0 vs 2) |
|
||||
|
||||
*All other settings are identical to Camera 1*
|
||||
|
||||
## 🔄 Recent Changes
|
||||
|
||||
### MP4 Format Update
|
||||
- **Added**: `video_format`, `video_codec`, `video_quality` fields
|
||||
- **Changed**: Default recording format from AVI to MP4
|
||||
- **Impact**: Requires service restart to take effect
|
||||
|
||||
### Current Status
|
||||
- ✅ Configuration updated with MP4 settings
|
||||
- ⚠️ Service restart required to apply changes
|
||||
- 📁 Existing AVI files remain accessible
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
1. **Target FPS = 0**: Both cameras use unlimited frame rate for maximum capture speed
|
||||
2. **Auto Recording**: Both cameras automatically start recording when their respective machines turn on
|
||||
3. **White Balance**: Manual white balance settings optimized for each camera's environment
|
||||
4. **Storage**: Each camera has its own dedicated storage directory
|
||||
5. **Video Quality**: Set to 95/100 for high-quality recordings with MP4 compression benefits
|
||||
|
||||
## 🔧 Configuration Management
|
||||
|
||||
To modify these settings:
|
||||
1. Edit `config.json` file
|
||||
2. Restart the camera service: `sudo ./start_system.sh`
|
||||
3. Verify changes via API: `GET /cameras/{camera_name}/config`
|
||||
|
||||
For real-time settings (exposure, gain, fps), use the API without restart:
|
||||
```bash
|
||||
PUT /cameras/{camera_name}/config
|
||||
```
|
||||
211
web/API Documentations/docs/MP4_FORMAT_UPDATE.md
Normal file
211
web/API Documentations/docs/MP4_FORMAT_UPDATE.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# 🎥 MP4 Video Format Update - Frontend Integration Guide
|
||||
|
||||
## Overview
|
||||
The USDA Vision Camera System has been updated to record videos in **MP4 format** instead of AVI format for better streaming compatibility and smaller file sizes.
|
||||
|
||||
## 🔄 What Changed
|
||||
|
||||
### Video Format
|
||||
- **Before**: AVI files with XVID codec (`.avi` extension)
|
||||
- **After**: MP4 files with MPEG-4 codec (`.mp4` extension)
|
||||
|
||||
### File Extensions
|
||||
- All new video recordings now use `.mp4` extension
|
||||
- Existing `.avi` files remain accessible and functional
|
||||
- File size reduction: ~40% smaller than equivalent AVI files
|
||||
|
||||
### API Response Updates
|
||||
New fields added to camera configuration responses:
|
||||
|
||||
```json
|
||||
{
|
||||
"video_format": "mp4", // File format: "mp4" or "avi"
|
||||
"video_codec": "mp4v", // Video codec: "mp4v", "XVID", "MJPG"
|
||||
"video_quality": 95 // Quality: 0-100 (higher = better)
|
||||
}
|
||||
```
|
||||
|
||||
## 🌐 Frontend Impact
|
||||
|
||||
### 1. Video Player Compatibility
|
||||
**✅ Better Browser Support**
|
||||
- MP4 format has native support in all modern browsers
|
||||
- No need for additional codecs or plugins
|
||||
- Better mobile device compatibility (iOS/Android)
|
||||
|
||||
### 2. File Handling Updates
|
||||
**File Extension Handling**
|
||||
```javascript
|
||||
// Update file extension checks
|
||||
const isVideoFile = (filename) => {
|
||||
return filename.endsWith('.mp4') || filename.endsWith('.avi');
|
||||
};
|
||||
|
||||
// Video MIME type detection
|
||||
const getVideoMimeType = (filename) => {
|
||||
if (filename.endsWith('.mp4')) return 'video/mp4';
|
||||
if (filename.endsWith('.avi')) return 'video/x-msvideo';
|
||||
return 'video/mp4'; // default
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Video Streaming
|
||||
**Improved Streaming Performance**
|
||||
```javascript
|
||||
// MP4 files can be streamed directly without conversion
|
||||
const videoUrl = `/api/videos/${videoId}/stream`;
|
||||
|
||||
// For HTML5 video element
|
||||
<video controls>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
```
|
||||
|
||||
### 4. File Size Display
|
||||
**Updated Size Expectations**
|
||||
- MP4 files are ~40% smaller than equivalent AVI files
|
||||
- Update any file size warnings or storage calculations
|
||||
- Better compression means faster downloads and uploads
|
||||
|
||||
## 📡 API Changes
|
||||
|
||||
### Camera Configuration Endpoint
|
||||
**GET** `/cameras/{camera_name}/config`
|
||||
|
||||
**New Response Fields:**
|
||||
```json
|
||||
{
|
||||
"name": "camera1",
|
||||
"machine_topic": "blower_separator",
|
||||
"storage_path": "/storage/camera1",
|
||||
"exposure_ms": 0.3,
|
||||
"gain": 4.0,
|
||||
"target_fps": 0,
|
||||
"enabled": true,
|
||||
"video_format": "mp4",
|
||||
"video_codec": "mp4v",
|
||||
"video_quality": 95,
|
||||
"auto_start_recording_enabled": true,
|
||||
"auto_recording_max_retries": 3,
|
||||
"auto_recording_retry_delay_seconds": 2,
|
||||
|
||||
// ... other existing fields
|
||||
}
|
||||
```
|
||||
|
||||
### Video Listing Endpoints
|
||||
**File Extension Updates**
|
||||
- Video files in responses will now have `.mp4` extensions
|
||||
- Existing `.avi` files will still appear in listings
|
||||
- Filter by both extensions when needed
|
||||
|
||||
## 🔧 Configuration Options
|
||||
|
||||
### Video Format Settings
|
||||
```json
|
||||
{
|
||||
"video_format": "mp4", // Options: "mp4", "avi"
|
||||
"video_codec": "mp4v", // Options: "mp4v", "XVID", "MJPG"
|
||||
"video_quality": 95 // Range: 0-100 (higher = better quality)
|
||||
}
|
||||
```
|
||||
|
||||
### Recommended Settings
|
||||
- **Production**: `"mp4"` format, `"mp4v"` codec, `95` quality
|
||||
- **Storage Optimized**: `"mp4"` format, `"mp4v"` codec, `85` quality
|
||||
- **Legacy Mode**: `"avi"` format, `"XVID"` codec, `95` quality
|
||||
|
||||
## 🎯 Frontend Implementation Checklist
|
||||
|
||||
### ✅ Video Player Updates
|
||||
- [ ] Verify HTML5 video player works with MP4 files
|
||||
- [ ] Update video MIME type handling
|
||||
- [ ] Test streaming performance with new format
|
||||
|
||||
### ✅ File Management
|
||||
- [ ] Update file extension filters to include `.mp4`
|
||||
- [ ] Modify file type detection logic
|
||||
- [ ] Update download/upload handling for MP4 files
|
||||
|
||||
### ✅ UI/UX Updates
|
||||
- [ ] Update file size expectations in UI
|
||||
- [ ] Modify any format-specific icons or indicators
|
||||
- [ ] Update help text or tooltips mentioning video formats
|
||||
|
||||
### ✅ Configuration Interface
|
||||
- [ ] Add video format settings to camera config UI
|
||||
- [ ] Include video quality slider/selector
|
||||
- [ ] Add restart warning for video format changes
|
||||
|
||||
### ✅ Testing
|
||||
- [ ] Test video playback with new MP4 files
|
||||
- [ ] Verify backward compatibility with existing AVI files
|
||||
- [ ] Test streaming performance and loading times
|
||||
|
||||
## 🔄 Backward Compatibility
|
||||
|
||||
### Existing AVI Files
|
||||
- All existing `.avi` files remain fully functional
|
||||
- No conversion or migration required
|
||||
- Video player should handle both formats
|
||||
|
||||
### API Compatibility
|
||||
- All existing API endpoints continue to work
|
||||
- New fields are additive (won't break existing code)
|
||||
- Default values provided for new configuration fields
|
||||
|
||||
## 📊 Performance Benefits
|
||||
|
||||
### File Size Reduction
|
||||
```
|
||||
Example 5-minute recording at 1280x1024:
|
||||
- AVI/XVID: ~180 MB
|
||||
- MP4/MPEG-4: ~108 MB (40% reduction)
|
||||
```
|
||||
|
||||
### Streaming Improvements
|
||||
- Faster initial load times
|
||||
- Better progressive download support
|
||||
- Reduced bandwidth usage
|
||||
- Native browser optimization
|
||||
|
||||
### Storage Efficiency
|
||||
- More recordings fit in same storage space
|
||||
- Faster backup and transfer operations
|
||||
- Reduced storage costs over time
|
||||
|
||||
## 🚨 Important Notes
|
||||
|
||||
### Restart Required
|
||||
- Video format changes require camera service restart
|
||||
- Mark video format settings as "restart required" in UI
|
||||
- Provide clear user feedback about restart necessity
|
||||
|
||||
### Browser Compatibility
|
||||
- MP4 format supported in all modern browsers
|
||||
- Better mobile device support than AVI
|
||||
- No additional plugins or codecs needed
|
||||
|
||||
### Quality Assurance
|
||||
- Video quality maintained at 95/100 setting
|
||||
- No visual degradation compared to AVI
|
||||
- High bitrate ensures professional quality
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- [API Documentation](API_DOCUMENTATION.md) - Complete API reference
|
||||
- [Camera Configuration API](api/CAMERA_CONFIG_API.md) - Detailed config options
|
||||
- [Video Streaming Guide](VIDEO_STREAMING.md) - Streaming implementation
|
||||
- [MP4 Conversion Summary](../MP4_CONVERSION_SUMMARY.md) - Technical details
|
||||
|
||||
## 📞 Support
|
||||
|
||||
If you encounter any issues with the MP4 format update:
|
||||
|
||||
1. **Video Playback Issues**: Check browser console for codec errors
|
||||
2. **File Size Concerns**: Verify quality settings in camera config
|
||||
3. **Streaming Problems**: Test with both MP4 and AVI files for comparison
|
||||
4. **API Integration**: Refer to updated API documentation
|
||||
|
||||
The MP4 format provides better web compatibility and performance while maintaining the same high video quality required for the USDA vision system.
|
||||
212
web/API Documentations/docs/PROJECT_COMPLETE.md
Normal file
212
web/API Documentations/docs/PROJECT_COMPLETE.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# 🎉 USDA Vision Camera System - PROJECT COMPLETE!
|
||||
|
||||
## ✅ Final Status: READY FOR PRODUCTION
|
||||
|
||||
The USDA Vision Camera System has been successfully implemented, tested, and documented. All requirements have been met and the system is production-ready.
|
||||
|
||||
## 📋 Completed Requirements
|
||||
|
||||
### ✅ Core Functionality
|
||||
- **MQTT Integration**: Dual topic listening for machine states
|
||||
- **Automatic Recording**: Camera recording triggered by machine on/off states
|
||||
- **GigE Camera Support**: Full integration with camera SDK library
|
||||
- **Multi-threading**: Concurrent MQTT + camera monitoring + recording
|
||||
- **File Management**: Timestamp-based naming in organized directories
|
||||
|
||||
### ✅ Advanced Features
|
||||
- **REST API**: Complete FastAPI server with all endpoints
|
||||
- **WebSocket Support**: Real-time updates for dashboard integration
|
||||
- **Time Synchronization**: Atlanta, Georgia timezone with NTP sync
|
||||
- **Storage Management**: File indexing, cleanup, and statistics
|
||||
- **Comprehensive Logging**: Rotating logs with error tracking
|
||||
- **Configuration System**: JSON-based configuration management
|
||||
|
||||
### ✅ Documentation & Testing
|
||||
- **Complete README**: Installation, usage, API docs, troubleshooting
|
||||
- **Test Suite**: Comprehensive system testing (`test_system.py`)
|
||||
- **Time Verification**: Timezone and sync testing (`check_time.py`)
|
||||
- **Startup Scripts**: Easy deployment with `start_system.sh`
|
||||
- **Clean Repository**: Organized structure with proper .gitignore
|
||||
|
||||
## 🏗️ Final Project Structure
|
||||
|
||||
```
|
||||
USDA-Vision-Cameras/
|
||||
├── README.md # Complete documentation
|
||||
├── main.py # System entry point
|
||||
├── config.json # System configuration
|
||||
├── requirements.txt # Python dependencies
|
||||
├── pyproject.toml # UV package configuration
|
||||
├── .gitignore # Git ignore rules
|
||||
├── start_system.sh # Startup script
|
||||
├── setup_timezone.sh # Time sync setup
|
||||
├── test_system.py # System test suite
|
||||
├── check_time.py # Time verification
|
||||
├── test_timezone.py # Timezone testing
|
||||
├── usda_vision_system/ # Main application
|
||||
│ ├── core/ # Core functionality
|
||||
│ ├── mqtt/ # MQTT integration
|
||||
│ ├── camera/ # Camera management
|
||||
│ ├── storage/ # File management
|
||||
│ ├── api/ # REST API server
|
||||
│ └── main.py # Application coordinator
|
||||
├── camera_sdk/ # GigE camera SDK library
|
||||
├── demos/ # Demo and example code
|
||||
│ ├── cv_grab*.py # Camera SDK usage examples
|
||||
│ └── mqtt_*.py # MQTT demo scripts
|
||||
├── storage/ # Recording storage
|
||||
│ ├── camera1/ # Camera 1 recordings
|
||||
│ └── camera2/ # Camera 2 recordings
|
||||
├── tests/ # Test files and legacy tests
|
||||
├── notebooks/ # Jupyter notebooks
|
||||
└── docs/ # Documentation files
|
||||
```
|
||||
|
||||
## 🚀 How to Deploy
|
||||
|
||||
### 1. Clone and Setup
|
||||
```bash
|
||||
git clone https://github.com/your-username/USDA-Vision-Cameras.git
|
||||
cd USDA-Vision-Cameras
|
||||
uv sync
|
||||
```
|
||||
|
||||
### 2. Configure System
|
||||
```bash
|
||||
# Edit config.json for your environment
|
||||
# Set MQTT broker, camera settings, storage paths
|
||||
```
|
||||
|
||||
### 3. Setup Time Sync
|
||||
```bash
|
||||
./setup_timezone.sh
|
||||
```
|
||||
|
||||
### 4. Test System
|
||||
```bash
|
||||
python test_system.py
|
||||
```
|
||||
|
||||
### 5. Start System
|
||||
```bash
|
||||
./start_system.sh
|
||||
```
|
||||
|
||||
## 🌐 API Integration
|
||||
|
||||
### Dashboard Integration
|
||||
```javascript
|
||||
// React component example
|
||||
const systemStatus = await fetch('http://localhost:8000/system/status');
|
||||
const cameras = await fetch('http://localhost:8000/cameras');
|
||||
|
||||
// WebSocket for real-time updates
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
// Handle real-time system updates
|
||||
};
|
||||
```
|
||||
|
||||
### Manual Control
|
||||
```bash
|
||||
# Start recording manually
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording
|
||||
|
||||
# Stop recording manually
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
|
||||
# Get system status
|
||||
curl http://localhost:8000/system/status
|
||||
```
|
||||
|
||||
## 📊 System Capabilities
|
||||
|
||||
### Discovered Hardware
|
||||
- **2 GigE Cameras**: Blower-Yield-Cam, Cracker-Cam
|
||||
- **Network Ready**: Cameras accessible at 192.168.1.165, 192.168.1.167
|
||||
- **MQTT Ready**: Configured for broker at 192.168.1.110
|
||||
|
||||
### Recording Features
|
||||
- **Automatic Start/Stop**: Based on MQTT machine states
|
||||
- **Timezone Aware**: Atlanta time timestamps (EST/EDT)
|
||||
- **Organized Storage**: Separate directories per camera
|
||||
- **File Naming**: `camera1_recording_20250725_213000.avi`
|
||||
- **Manual Control**: API endpoints for manual recording
|
||||
|
||||
### Monitoring Features
|
||||
- **Real-time Status**: Camera and machine state monitoring
|
||||
- **Health Checks**: Automatic system health verification
|
||||
- **Performance Tracking**: Recording metrics and system stats
|
||||
- **Error Handling**: Comprehensive error tracking and recovery
|
||||
|
||||
## 🔧 Maintenance
|
||||
|
||||
### Regular Tasks
|
||||
- **Log Monitoring**: Check `usda_vision_system.log`
|
||||
- **Storage Cleanup**: Automatic cleanup of old recordings
|
||||
- **Time Sync**: Automatic NTP synchronization
|
||||
- **Health Checks**: Built-in system monitoring
|
||||
|
||||
### Troubleshooting
|
||||
- **Test Suite**: `python test_system.py`
|
||||
- **Time Check**: `python check_time.py`
|
||||
- **API Health**: `curl http://localhost:8000/health`
|
||||
- **Debug Mode**: `python main.py --log-level DEBUG`
|
||||
|
||||
## 🎯 Production Readiness
|
||||
|
||||
### ✅ All Tests Passing
|
||||
- System initialization: ✅
|
||||
- Camera discovery: ✅ (2 cameras found)
|
||||
- MQTT configuration: ✅
|
||||
- Storage setup: ✅
|
||||
- Time synchronization: ✅
|
||||
- API endpoints: ✅
|
||||
|
||||
### ✅ Documentation Complete
|
||||
- Installation guide: ✅
|
||||
- Configuration reference: ✅
|
||||
- API documentation: ✅
|
||||
- Troubleshooting guide: ✅
|
||||
- Integration examples: ✅
|
||||
|
||||
### ✅ Production Features
|
||||
- Error handling: ✅
|
||||
- Logging system: ✅
|
||||
- Time synchronization: ✅
|
||||
- Storage management: ✅
|
||||
- API security: ✅
|
||||
- Performance monitoring: ✅
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
The system is now ready for:
|
||||
|
||||
1. **Production Deployment**: Deploy on target hardware
|
||||
2. **Dashboard Integration**: Connect to React + Supabase dashboard
|
||||
3. **MQTT Configuration**: Connect to production MQTT broker
|
||||
4. **Camera Calibration**: Fine-tune camera settings for production
|
||||
5. **Monitoring Setup**: Configure production monitoring and alerts
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For ongoing support:
|
||||
- **Documentation**: Complete README.md with troubleshooting
|
||||
- **Test Suite**: Comprehensive diagnostic tools
|
||||
- **Logging**: Detailed system logs for debugging
|
||||
- **API Health**: Built-in health check endpoints
|
||||
|
||||
---
|
||||
|
||||
**🎊 PROJECT STATUS: COMPLETE AND PRODUCTION-READY! 🎊**
|
||||
|
||||
The USDA Vision Camera System is fully implemented, tested, and documented. All original requirements have been met, and the system is ready for production deployment with your React dashboard integration.
|
||||
|
||||
**Key Achievements:**
|
||||
- ✅ Dual MQTT topic monitoring
|
||||
- ✅ Automatic camera recording
|
||||
- ✅ Atlanta timezone synchronization
|
||||
- ✅ Complete REST API
|
||||
- ✅ Comprehensive documentation
|
||||
- ✅ Production-ready deployment
|
||||
276
web/API Documentations/docs/REACT_INTEGRATION_GUIDE.md
Normal file
276
web/API Documentations/docs/REACT_INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# 🚀 React Frontend Integration Guide - MP4 Update
|
||||
|
||||
## 🎯 Quick Summary for React Team
|
||||
|
||||
The camera system now records in **MP4 format** instead of AVI. This provides better web compatibility and smaller file sizes.
|
||||
|
||||
## 🔄 What You Need to Update
|
||||
|
||||
### 1. File Extension Handling
|
||||
```javascript
|
||||
// OLD: Only checked for .avi
|
||||
const isVideoFile = (filename) => filename.endsWith('.avi');
|
||||
|
||||
// NEW: Check for both formats
|
||||
const isVideoFile = (filename) => {
|
||||
return filename.endsWith('.mp4') || filename.endsWith('.avi');
|
||||
};
|
||||
|
||||
// Video MIME types
|
||||
const getVideoMimeType = (filename) => {
|
||||
if (filename.endsWith('.mp4')) return 'video/mp4';
|
||||
if (filename.endsWith('.avi')) return 'video/x-msvideo';
|
||||
return 'video/mp4'; // default for new files
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Video Player Component
|
||||
```jsx
|
||||
// MP4 files work better with HTML5 video
|
||||
const VideoPlayer = ({ videoUrl, filename }) => {
|
||||
const mimeType = getVideoMimeType(filename);
|
||||
|
||||
return (
|
||||
<video controls width="100%" height="auto">
|
||||
<source src={videoUrl} type={mimeType} />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Camera Configuration Interface
|
||||
Add these new fields to your camera config forms:
|
||||
|
||||
```jsx
|
||||
const CameraConfigForm = () => {
|
||||
const [config, setConfig] = useState({
|
||||
// ... existing fields
|
||||
video_format: 'mp4', // 'mp4' or 'avi'
|
||||
video_codec: 'mp4v', // 'mp4v', 'XVID', 'MJPG'
|
||||
video_quality: 95 // 0-100
|
||||
});
|
||||
|
||||
return (
|
||||
<form>
|
||||
{/* ... existing fields */}
|
||||
|
||||
<div className="video-settings">
|
||||
<h3>Video Recording Settings</h3>
|
||||
|
||||
<select
|
||||
value={config.video_format}
|
||||
onChange={(e) => setConfig({...config, video_format: e.target.value})}
|
||||
>
|
||||
<option value="mp4">MP4 (Recommended)</option>
|
||||
<option value="avi">AVI (Legacy)</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={config.video_codec}
|
||||
onChange={(e) => setConfig({...config, video_codec: e.target.value})}
|
||||
>
|
||||
<option value="mp4v">MPEG-4 (mp4v)</option>
|
||||
<option value="XVID">Xvid</option>
|
||||
<option value="MJPG">Motion JPEG</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="100"
|
||||
value={config.video_quality}
|
||||
onChange={(e) => setConfig({...config, video_quality: parseInt(e.target.value)})}
|
||||
/>
|
||||
<label>Quality: {config.video_quality}%</label>
|
||||
|
||||
<div className="warning">
|
||||
⚠️ Video format changes require camera restart
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 📡 API Response Changes
|
||||
|
||||
### Camera Configuration Response
|
||||
```json
|
||||
{
|
||||
"name": "camera1",
|
||||
"machine_topic": "blower_separator",
|
||||
"storage_path": "/storage/camera1",
|
||||
"exposure_ms": 0.3,
|
||||
"gain": 4.0,
|
||||
"target_fps": 0,
|
||||
"enabled": true,
|
||||
"video_format": "mp4",
|
||||
"video_codec": "mp4v",
|
||||
"video_quality": 95,
|
||||
"auto_start_recording_enabled": true,
|
||||
"auto_recording_max_retries": 3,
|
||||
"auto_recording_retry_delay_seconds": 2,
|
||||
|
||||
// ... other existing fields
|
||||
}
|
||||
```
|
||||
|
||||
### Video File Listings
|
||||
```json
|
||||
{
|
||||
"videos": [
|
||||
{
|
||||
"file_id": "camera1_recording_20250804_143022.mp4",
|
||||
"filename": "camera1_recording_20250804_143022.mp4",
|
||||
"format": "mp4",
|
||||
"file_size_bytes": 31457280,
|
||||
"created_at": "2025-08-04T14:30:22"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 UI/UX Improvements
|
||||
|
||||
### File Size Display
|
||||
```javascript
|
||||
// MP4 files are ~40% smaller
|
||||
const formatFileSize = (bytes) => {
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
// Show format in file listings
|
||||
const FileListItem = ({ video }) => (
|
||||
<div className="file-item">
|
||||
<span className="filename">{video.filename}</span>
|
||||
<span className={`format ${video.format}`}>
|
||||
{video.format.toUpperCase()}
|
||||
</span>
|
||||
<span className="size">{formatFileSize(video.file_size_bytes)}</span>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### Format Indicators
|
||||
```css
|
||||
.format.mp4 {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.format.avi {
|
||||
background: #FF9800;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
```
|
||||
|
||||
## ⚡ Performance Benefits
|
||||
|
||||
### Streaming Improvements
|
||||
- **Faster Loading**: MP4 files start playing sooner
|
||||
- **Better Seeking**: More responsive video scrubbing
|
||||
- **Mobile Friendly**: Better iOS/Android compatibility
|
||||
- **Bandwidth Savings**: 40% smaller files = faster transfers
|
||||
|
||||
### Implementation Tips
|
||||
```javascript
|
||||
// Preload video metadata for better UX
|
||||
const VideoThumbnail = ({ videoUrl }) => (
|
||||
<video
|
||||
preload="metadata"
|
||||
poster={`${videoUrl}?t=1`} // Thumbnail at 1 second
|
||||
onLoadedMetadata={(e) => {
|
||||
console.log('Duration:', e.target.duration);
|
||||
}}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
</video>
|
||||
);
|
||||
```
|
||||
|
||||
## 🔧 Configuration Management
|
||||
|
||||
### Restart Warning Component
|
||||
```jsx
|
||||
const RestartWarning = ({ show }) => {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="alert alert-warning">
|
||||
<strong>⚠️ Restart Required</strong>
|
||||
<p>Video format changes require a camera service restart to take effect.</p>
|
||||
<button onClick={handleRestart}>Restart Camera Service</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Settings Validation
|
||||
```javascript
|
||||
const validateVideoSettings = (settings) => {
|
||||
const errors = {};
|
||||
|
||||
if (!['mp4', 'avi'].includes(settings.video_format)) {
|
||||
errors.video_format = 'Must be mp4 or avi';
|
||||
}
|
||||
|
||||
if (!['mp4v', 'XVID', 'MJPG'].includes(settings.video_codec)) {
|
||||
errors.video_codec = 'Invalid codec';
|
||||
}
|
||||
|
||||
if (settings.video_quality < 50 || settings.video_quality > 100) {
|
||||
errors.video_quality = 'Quality must be between 50-100';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
```
|
||||
|
||||
## 📱 Mobile Considerations
|
||||
|
||||
### Responsive Video Player
|
||||
```jsx
|
||||
const ResponsiveVideoPlayer = ({ videoUrl, filename }) => (
|
||||
<div className="video-container">
|
||||
<video
|
||||
controls
|
||||
playsInline // Important for iOS
|
||||
preload="metadata"
|
||||
style={{ width: '100%', height: 'auto' }}
|
||||
>
|
||||
<source src={videoUrl} type={getVideoMimeType(filename)} />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
- [ ] Video playback works with new MP4 files
|
||||
- [ ] File extension filtering includes both .mp4 and .avi
|
||||
- [ ] Camera configuration UI shows video format options
|
||||
- [ ] Restart warning appears for video format changes
|
||||
- [ ] File size displays are updated for smaller MP4 files
|
||||
- [ ] Mobile video playback works correctly
|
||||
- [ ] Video streaming performance is improved
|
||||
- [ ] Backward compatibility with existing AVI files
|
||||
|
||||
## 📞 Support
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. **Video won't play**: Check browser console for codec errors
|
||||
2. **File size unexpected**: Verify quality settings in camera config
|
||||
3. **Streaming slow**: Compare MP4 vs AVI performance
|
||||
4. **Mobile issues**: Ensure `playsInline` attribute is set
|
||||
|
||||
The MP4 update provides significant improvements in web compatibility and performance while maintaining full backward compatibility with existing AVI files.
|
||||
100
web/API Documentations/docs/README.md
Normal file
100
web/API Documentations/docs/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# USDA Vision Camera System - Documentation
|
||||
|
||||
This directory contains detailed documentation for the USDA Vision Camera System.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
### 🚀 [API_DOCUMENTATION.md](API_DOCUMENTATION.md) **⭐ NEW**
|
||||
**Complete API reference documentation** covering all endpoints, features, and recent enhancements:
|
||||
- System status and health monitoring
|
||||
- Camera management and configuration
|
||||
- Recording control with dynamic settings
|
||||
- Auto-recording management
|
||||
- MQTT and machine status
|
||||
- Storage and file management
|
||||
- Camera recovery and diagnostics
|
||||
- Live streaming capabilities
|
||||
- WebSocket real-time updates
|
||||
- Quick start examples and migration notes
|
||||
|
||||
### ⚡ [API_QUICK_REFERENCE.md](API_QUICK_REFERENCE.md) **⭐ NEW**
|
||||
**Quick reference card** for the most commonly used API endpoints with curl examples and response formats.
|
||||
|
||||
### 📋 [PROJECT_COMPLETE.md](PROJECT_COMPLETE.md)
|
||||
Complete project overview and final status documentation. Contains:
|
||||
- Project completion status
|
||||
- Final system architecture
|
||||
- Deployment instructions
|
||||
- Production readiness checklist
|
||||
|
||||
### 🎥 [MP4_FORMAT_UPDATE.md](MP4_FORMAT_UPDATE.md) **⭐ NEW**
|
||||
**Frontend integration guide** for the MP4 video format update:
|
||||
- Video format changes from AVI to MP4
|
||||
- Frontend implementation checklist
|
||||
- API response updates
|
||||
- Performance benefits and browser compatibility
|
||||
|
||||
### 🚀 [REACT_INTEGRATION_GUIDE.md](REACT_INTEGRATION_GUIDE.md) **⭐ NEW**
|
||||
**Quick reference for React developers** implementing the MP4 format changes:
|
||||
- Code examples and components
|
||||
- File handling updates
|
||||
- Configuration interface
|
||||
- Testing checklist
|
||||
|
||||
### 📋 [CURRENT_CONFIGURATION.md](CURRENT_CONFIGURATION.md) **⭐ NEW**
|
||||
**Complete current system configuration reference**:
|
||||
- Exact config.json structure with all current values
|
||||
- Field-by-field documentation
|
||||
- Camera-specific settings comparison
|
||||
- MQTT topics and machine mappings
|
||||
|
||||
### 🎬 [VIDEO_STREAMING.md](VIDEO_STREAMING.md) **⭐ UPDATED**
|
||||
**Complete video streaming module documentation**:
|
||||
- Comprehensive API endpoint documentation
|
||||
- Authentication and security information
|
||||
- Error handling and troubleshooting
|
||||
- Performance optimization guidelines
|
||||
|
||||
### 🤖 [AI_AGENT_VIDEO_INTEGRATION_GUIDE.md](AI_AGENT_VIDEO_INTEGRATION_GUIDE.md) **⭐ NEW**
|
||||
**Complete integration guide for AI agents and external systems**:
|
||||
- Step-by-step integration workflow
|
||||
- Programming language examples (Python, JavaScript)
|
||||
- Error handling and debugging strategies
|
||||
- Performance optimization recommendations
|
||||
|
||||
### 🔧 [API_CHANGES_SUMMARY.md](API_CHANGES_SUMMARY.md)
|
||||
Summary of API changes and enhancements made to the system.
|
||||
|
||||
### 📷 [CAMERA_RECOVERY_GUIDE.md](CAMERA_RECOVERY_GUIDE.md)
|
||||
Guide for camera recovery procedures and troubleshooting camera-related issues.
|
||||
|
||||
### 📡 [MQTT_LOGGING_GUIDE.md](MQTT_LOGGING_GUIDE.md)
|
||||
Comprehensive guide for MQTT logging configuration and troubleshooting.
|
||||
|
||||
## Main Documentation
|
||||
|
||||
The main system documentation is located in the root directory:
|
||||
- **[../README.md](../README.md)** - Primary system documentation with installation, configuration, and usage instructions
|
||||
|
||||
## Additional Resources
|
||||
|
||||
### Demo Code
|
||||
- **[../demos/](../demos/)** - Demo scripts and camera SDK examples
|
||||
|
||||
### Test Files
|
||||
- **[../tests/](../tests/)** - Test scripts and legacy test files
|
||||
|
||||
### Jupyter Notebooks
|
||||
- **[../notebooks/](../notebooks/)** - Interactive notebooks for system exploration and testing
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [System Installation](../README.md#installation)
|
||||
- [Configuration Guide](../README.md#configuration)
|
||||
- [API Documentation](../README.md#api-reference)
|
||||
- [Troubleshooting](../README.md#troubleshooting)
|
||||
- [Camera SDK Examples](../demos/camera_sdk_examples/)
|
||||
|
||||
## Support
|
||||
|
||||
For technical support and questions, refer to the main [README.md](../README.md) troubleshooting section or check the system logs.
|
||||
601
web/API Documentations/docs/VIDEO_STREAMING.md
Normal file
601
web/API Documentations/docs/VIDEO_STREAMING.md
Normal file
@@ -0,0 +1,601 @@
|
||||
# 🎬 Video Streaming Module
|
||||
|
||||
The USDA Vision Camera System now includes a modular video streaming system that provides YouTube-like video playback capabilities for your React web application.
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- **Progressive Streaming** - True chunked streaming for web browsers (no download required)
|
||||
- **HTTP Range Request Support** - Enables seeking and progressive download with 206 Partial Content
|
||||
- **Native MP4 Support** - Direct streaming of MP4 files optimized for web playback
|
||||
- **Memory Efficient** - 8KB chunked delivery, no large file loading into memory
|
||||
- **Browser Compatible** - Works with HTML5 `<video>` tag in all modern browsers
|
||||
- **Intelligent Caching** - Optimized streaming performance with byte-range caching
|
||||
- **Thumbnail Generation** - Extract preview images from videos
|
||||
- **Modular Architecture** - Clean separation of concerns
|
||||
- **No Authentication Required** - Open access for internal network use
|
||||
- **CORS Enabled** - Ready for web browser integration
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The video module follows clean architecture principles:
|
||||
|
||||
```
|
||||
usda_vision_system/video/
|
||||
├── domain/ # Business logic (pure Python)
|
||||
├── infrastructure/ # External dependencies (OpenCV, FFmpeg)
|
||||
├── application/ # Use cases and orchestration
|
||||
├── presentation/ # HTTP controllers and API routes
|
||||
└── integration.py # Dependency injection and composition
|
||||
```
|
||||
|
||||
## 🚀 API Endpoints
|
||||
|
||||
### List Videos
|
||||
```http
|
||||
GET /videos/
|
||||
```
|
||||
**Query Parameters:**
|
||||
- `camera_name` (optional): Filter by camera name
|
||||
- `start_date` (optional): Filter videos created after this date (ISO format: 2025-08-04T14:30:22)
|
||||
- `end_date` (optional): Filter videos created before this date (ISO format: 2025-08-04T14:30:22)
|
||||
- `limit` (optional): Maximum results (default: 50, max: 1000)
|
||||
- `include_metadata` (optional): Include video metadata (default: false)
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl "http://localhost:8000/videos/?camera_name=camera1&include_metadata=true&limit=10"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"videos": [
|
||||
{
|
||||
"file_id": "camera1_auto_blower_separator_20250804_143022.mp4",
|
||||
"camera_name": "camera1",
|
||||
"filename": "camera1_auto_blower_separator_20250804_143022.mp4",
|
||||
"file_size_bytes": 31457280,
|
||||
"format": "mp4",
|
||||
"status": "completed",
|
||||
"created_at": "2025-08-04T14:30:22",
|
||||
"start_time": "2025-08-04T14:30:22",
|
||||
"end_time": "2025-08-04T14:32:22",
|
||||
"machine_trigger": "blower_separator",
|
||||
"is_streamable": true,
|
||||
"needs_conversion": false,
|
||||
"metadata": {
|
||||
"duration_seconds": 120.5,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"codec": "mp4v",
|
||||
"bitrate": 5000000,
|
||||
"aspect_ratio": 1.777
|
||||
}
|
||||
}
|
||||
],
|
||||
"total_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Stream Video
|
||||
```http
|
||||
GET /videos/{file_id}/stream
|
||||
```
|
||||
**Headers:**
|
||||
- `Range: bytes=0-1023` (optional): Request specific byte range for seeking
|
||||
|
||||
**Example Requests:**
|
||||
```bash
|
||||
# Stream entire video (progressive streaming)
|
||||
curl http://localhost:8000/videos/camera1_auto_blower_separator_20250805_123329.mp4/stream
|
||||
|
||||
# Stream specific byte range (for seeking)
|
||||
curl -H "Range: bytes=0-1023" \
|
||||
http://localhost:8000/videos/camera1_auto_blower_separator_20250805_123329.mp4/stream
|
||||
```
|
||||
|
||||
**Response Headers:**
|
||||
- `Accept-Ranges: bytes`
|
||||
- `Content-Length: {size}`
|
||||
- `Content-Range: bytes {start}-{end}/{total}` (for range requests)
|
||||
- `Cache-Control: public, max-age=3600`
|
||||
- `Content-Type: video/mp4`
|
||||
|
||||
**Streaming Implementation:**
|
||||
- ✅ **Progressive Streaming**: Uses FastAPI `StreamingResponse` with 8KB chunks
|
||||
- ✅ **HTTP Range Requests**: Returns 206 Partial Content for seeking
|
||||
- ✅ **Memory Efficient**: No large file loading, streams directly from disk
|
||||
- ✅ **Browser Compatible**: Works with HTML5 `<video>` tag playback
|
||||
- ✅ **Chunked Delivery**: Optimal 8KB chunk size for smooth playback
|
||||
- ✅ **CORS Enabled**: Ready for web browser integration
|
||||
|
||||
**Response Status Codes:**
|
||||
- `200 OK`: Full video streaming (progressive chunks)
|
||||
- `206 Partial Content`: Range request successful
|
||||
- `404 Not Found`: Video not found or not streamable
|
||||
- `416 Range Not Satisfiable`: Invalid range request
|
||||
|
||||
### Get Video Info
|
||||
```http
|
||||
GET /videos/{file_id}
|
||||
```
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl http://localhost:8000/videos/camera1_recording_20250804_143022.avi
|
||||
```
|
||||
|
||||
**Response includes complete metadata:**
|
||||
```json
|
||||
{
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"camera_name": "camera1",
|
||||
"filename": "camera1_recording_20250804_143022.avi",
|
||||
"file_size_bytes": 52428800,
|
||||
"format": "avi",
|
||||
"status": "completed",
|
||||
"created_at": "2025-08-04T14:30:22",
|
||||
"start_time": "2025-08-04T14:30:22",
|
||||
"end_time": "2025-08-04T14:32:22",
|
||||
"machine_trigger": "vibratory_conveyor",
|
||||
"is_streamable": true,
|
||||
"needs_conversion": true,
|
||||
"metadata": {
|
||||
"duration_seconds": 120.5,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"codec": "XVID",
|
||||
"bitrate": 5000000,
|
||||
"aspect_ratio": 1.777
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Thumbnail
|
||||
```http
|
||||
GET /videos/{file_id}/thumbnail?timestamp=5.0&width=320&height=240
|
||||
```
|
||||
**Query Parameters:**
|
||||
- `timestamp` (optional): Time position in seconds to extract thumbnail from (default: 1.0)
|
||||
- `width` (optional): Thumbnail width in pixels (default: 320)
|
||||
- `height` (optional): Thumbnail height in pixels (default: 240)
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl "http://localhost:8000/videos/camera1_recording_20250804_143022.avi/thumbnail?timestamp=5.0&width=320&height=240" \
|
||||
--output thumbnail.jpg
|
||||
```
|
||||
|
||||
**Response**: JPEG image data with caching headers
|
||||
- `Content-Type: image/jpeg`
|
||||
- `Cache-Control: public, max-age=3600`
|
||||
|
||||
### Streaming Info
|
||||
```http
|
||||
GET /videos/{file_id}/info
|
||||
```
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl http://localhost:8000/videos/camera1_recording_20250804_143022.avi/info
|
||||
```
|
||||
|
||||
**Response**: Technical streaming details
|
||||
```json
|
||||
{
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"file_size_bytes": 52428800,
|
||||
"content_type": "video/x-msvideo",
|
||||
"supports_range_requests": true,
|
||||
"chunk_size_bytes": 262144
|
||||
}
|
||||
```
|
||||
|
||||
### Video Validation
|
||||
```http
|
||||
POST /videos/{file_id}/validate
|
||||
```
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/videos/camera1_recording_20250804_143022.avi/validate
|
||||
```
|
||||
|
||||
**Response**: Validation status
|
||||
```json
|
||||
{
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"is_valid": true
|
||||
}
|
||||
```
|
||||
|
||||
### Cache Management
|
||||
```http
|
||||
POST /videos/{file_id}/cache/invalidate
|
||||
```
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/videos/camera1_recording_20250804_143022.avi/cache/invalidate
|
||||
```
|
||||
|
||||
**Response**: Cache invalidation status
|
||||
```json
|
||||
{
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"cache_invalidated": true
|
||||
}
|
||||
```
|
||||
|
||||
### Admin: Cache Cleanup
|
||||
```http
|
||||
POST /admin/videos/cache/cleanup?max_size_mb=100
|
||||
```
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/admin/videos/cache/cleanup?max_size_mb=100"
|
||||
```
|
||||
|
||||
**Response**: Cache cleanup results
|
||||
```json
|
||||
{
|
||||
"cache_cleaned": true,
|
||||
"entries_removed": 15,
|
||||
"max_size_mb": 100
|
||||
}
|
||||
```
|
||||
|
||||
## 🌐 React Integration
|
||||
|
||||
### Basic Video Player
|
||||
```jsx
|
||||
function VideoPlayer({ fileId }) {
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
width="100%"
|
||||
preload="metadata"
|
||||
style={{ maxWidth: '800px' }}
|
||||
>
|
||||
<source
|
||||
src={`${API_BASE_URL}/videos/${fileId}/stream`}
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Player with Thumbnail
|
||||
```jsx
|
||||
function VideoPlayerWithThumbnail({ fileId }) {
|
||||
const [thumbnail, setThumbnail] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE_URL}/videos/${fileId}/thumbnail`)
|
||||
.then(response => response.blob())
|
||||
.then(blob => setThumbnail(URL.createObjectURL(blob)));
|
||||
}, [fileId]);
|
||||
|
||||
return (
|
||||
<video controls width="100%" poster={thumbnail}>
|
||||
<source
|
||||
src={`${API_BASE_URL}/videos/${fileId}/stream`}
|
||||
type="video/mp4"
|
||||
/>
|
||||
</video>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Video List Component
|
||||
```jsx
|
||||
function VideoList({ cameraName }) {
|
||||
const [videos, setVideos] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (cameraName) params.append('camera_name', cameraName);
|
||||
params.append('include_metadata', 'true');
|
||||
|
||||
fetch(`${API_BASE_URL}/videos/?${params}`)
|
||||
.then(response => response.json())
|
||||
.then(data => setVideos(data.videos));
|
||||
}, [cameraName]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{videos.map(video => (
|
||||
<VideoCard key={video.file_id} video={video} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
The video module is automatically initialized when the API server starts. Configuration options:
|
||||
|
||||
```python
|
||||
# In your API server initialization
|
||||
video_module = create_video_module(
|
||||
config=config,
|
||||
storage_manager=storage_manager,
|
||||
enable_caching=True, # Enable streaming cache
|
||||
enable_conversion=True # Enable format conversion
|
||||
)
|
||||
```
|
||||
|
||||
### Configuration Parameters
|
||||
- **`enable_caching`**: Enable/disable intelligent byte-range caching (default: True)
|
||||
- **`cache_size_mb`**: Maximum cache size in MB (default: 100)
|
||||
- **`cache_max_age_minutes`**: Cache entry expiration time (default: 30)
|
||||
- **`enable_conversion`**: Enable/disable automatic AVI to MP4 conversion (default: True)
|
||||
- **`conversion_quality`**: Video conversion quality: "low", "medium", "high" (default: "medium")
|
||||
|
||||
### System Requirements
|
||||
- **OpenCV**: Required for thumbnail generation and metadata extraction
|
||||
- **FFmpeg**: Optional, for video format conversion (graceful fallback if not available)
|
||||
- **Storage**: Sufficient disk space for video files and cache
|
||||
- **Memory**: Recommended 2GB+ RAM for caching and video processing
|
||||
|
||||
## 🔐 Authentication & Security
|
||||
|
||||
### Current Security Model
|
||||
**⚠️ IMPORTANT: No authentication is currently implemented.**
|
||||
|
||||
- **Open Access**: All video streaming endpoints are publicly accessible
|
||||
- **CORS Policy**: Currently set to allow all origins (`allow_origins=["*"]`)
|
||||
- **Network Security**: Designed for internal network use only
|
||||
- **No API Keys**: No authentication tokens or API keys required
|
||||
- **No Rate Limiting**: No request rate limiting currently implemented
|
||||
|
||||
### Security Considerations for Production
|
||||
|
||||
#### For Internal Network Deployment
|
||||
```bash
|
||||
# Current configuration is suitable for:
|
||||
# - Internal corporate networks
|
||||
# - Isolated network segments
|
||||
# - Development and testing environments
|
||||
```
|
||||
|
||||
#### For External Access (Recommendations)
|
||||
If you need to expose the video streaming API externally, consider implementing:
|
||||
|
||||
1. **Authentication Layer**
|
||||
```python
|
||||
# Example: Add JWT authentication
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
async def verify_token(token: str = Depends(security)):
|
||||
# Implement token verification logic
|
||||
pass
|
||||
```
|
||||
|
||||
2. **CORS Configuration**
|
||||
```python
|
||||
# Restrict CORS to specific domains
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://yourdomain.com"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"]
|
||||
)
|
||||
```
|
||||
|
||||
3. **Rate Limiting**
|
||||
```python
|
||||
# Example: Add rate limiting
|
||||
from slowapi import Limiter
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
@app.get("/videos/")
|
||||
@limiter.limit("10/minute")
|
||||
async def list_videos():
|
||||
pass
|
||||
```
|
||||
|
||||
4. **Network Security**
|
||||
- Use HTTPS/TLS for encrypted communication
|
||||
- Implement firewall rules to restrict access
|
||||
- Consider VPN access for remote users
|
||||
- Use reverse proxy (nginx) for additional security
|
||||
|
||||
### Access Control Summary
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Current Access Model │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Authentication: ❌ None │
|
||||
│ Authorization: ❌ None │
|
||||
│ CORS: ✅ Enabled (all origins) │
|
||||
│ Rate Limiting: ❌ None │
|
||||
│ HTTPS: ⚠️ Depends on deployment │
|
||||
│ Network Security: ⚠️ Firewall/VPN recommended │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 📊 Performance
|
||||
|
||||
- **Caching**: Intelligent byte-range caching reduces disk I/O
|
||||
- **Adaptive Chunking**: Optimal chunk sizes based on file size
|
||||
- **Range Requests**: Only download needed portions
|
||||
- **Format Conversion**: Automatic conversion to web-compatible formats
|
||||
|
||||
## 🛠️ Service Management
|
||||
|
||||
### Restart Service
|
||||
```bash
|
||||
sudo systemctl restart usda-vision-camera
|
||||
```
|
||||
|
||||
### Check Status
|
||||
```bash
|
||||
# Check video module status
|
||||
curl http://localhost:8000/system/video-module
|
||||
|
||||
# Check available videos
|
||||
curl http://localhost:8000/videos/
|
||||
```
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
sudo journalctl -u usda-vision-camera -f
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
Run the video module tests:
|
||||
```bash
|
||||
cd /home/alireza/USDA-vision-cameras
|
||||
PYTHONPATH=/home/alireza/USDA-vision-cameras python tests/test_video_module.py
|
||||
```
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Video Not Playing
|
||||
1. **Check if file exists**: `GET /videos/{file_id}`
|
||||
```bash
|
||||
curl http://localhost:8000/videos/camera1_recording_20250804_143022.avi
|
||||
```
|
||||
2. **Verify streaming info**: `GET /videos/{file_id}/info`
|
||||
```bash
|
||||
curl http://localhost:8000/videos/camera1_recording_20250804_143022.avi/info
|
||||
```
|
||||
3. **Test direct stream**: `GET /videos/{file_id}/stream`
|
||||
```bash
|
||||
curl -I http://localhost:8000/videos/camera1_recording_20250804_143022.avi/stream
|
||||
```
|
||||
4. **Validate video file**: `POST /videos/{file_id}/validate`
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/videos/camera1_recording_20250804_143022.avi/validate
|
||||
```
|
||||
|
||||
### Performance Issues
|
||||
1. **Check cache status**: Clean up cache if needed
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/admin/videos/cache/cleanup?max_size_mb=100"
|
||||
```
|
||||
2. **Monitor system resources**: Check CPU, memory, and disk usage
|
||||
3. **Adjust cache size**: Modify configuration parameters
|
||||
4. **Invalidate specific cache**: For updated files
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/videos/{file_id}/cache/invalidate
|
||||
```
|
||||
|
||||
### Format Issues
|
||||
- **AVI files**: Automatically converted to MP4 for web compatibility
|
||||
- **Conversion requires FFmpeg**: Optional dependency with graceful fallback
|
||||
- **Supported formats**: AVI (with conversion), MP4 (native), WebM (native)
|
||||
|
||||
### Common HTTP Status Codes
|
||||
- **200**: Success - Video streamed successfully
|
||||
- **206**: Partial Content - Range request successful
|
||||
- **404**: Not Found - Video file doesn't exist or isn't streamable
|
||||
- **416**: Range Not Satisfiable - Invalid range request
|
||||
- **500**: Internal Server Error - Failed to read video data or generate thumbnail
|
||||
|
||||
### Browser Compatibility
|
||||
- **Chrome/Chromium**: Full support for MP4 and range requests
|
||||
- **Firefox**: Full support for MP4 and range requests
|
||||
- **Safari**: Full support for MP4 and range requests
|
||||
- **Edge**: Full support for MP4 and range requests
|
||||
- **Mobile browsers**: Generally good support for MP4 streaming
|
||||
|
||||
### Error Scenarios and Solutions
|
||||
|
||||
#### Video File Issues
|
||||
```bash
|
||||
# Problem: Video not found (404)
|
||||
curl http://localhost:8000/videos/nonexistent_video.mp4
|
||||
# Response: {"detail": "Video nonexistent_video.mp4 not found"}
|
||||
# Solution: Verify file_id exists using list endpoint
|
||||
|
||||
# Problem: Video not streamable
|
||||
curl http://localhost:8000/videos/corrupted_video.avi/stream
|
||||
# Response: {"detail": "Video corrupted_video.avi not found or not streamable"}
|
||||
# Solution: Use validation endpoint to check file integrity
|
||||
```
|
||||
|
||||
#### Range Request Issues
|
||||
```bash
|
||||
# Problem: Invalid range request (416)
|
||||
curl -H "Range: bytes=999999999-" http://localhost:8000/videos/small_video.mp4/stream
|
||||
# Response: {"detail": "Invalid range request: Range exceeds file size"}
|
||||
# Solution: Check file size first using /info endpoint
|
||||
|
||||
# Problem: Malformed range header
|
||||
curl -H "Range: invalid-range" http://localhost:8000/videos/video.mp4/stream
|
||||
# Response: {"detail": "Invalid range request: Malformed range header"}
|
||||
# Solution: Use proper range format: "bytes=start-end"
|
||||
```
|
||||
|
||||
#### Thumbnail Generation Issues
|
||||
```bash
|
||||
# Problem: Thumbnail generation failed (404)
|
||||
curl http://localhost:8000/videos/audio_only.mp4/thumbnail
|
||||
# Response: {"detail": "Could not generate thumbnail for audio_only.mp4"}
|
||||
# Solution: Verify video has visual content and is not audio-only
|
||||
|
||||
# Problem: Invalid timestamp
|
||||
curl "http://localhost:8000/videos/short_video.mp4/thumbnail?timestamp=999"
|
||||
# Response: Returns thumbnail from last available frame
|
||||
# Solution: Check video duration first using metadata
|
||||
```
|
||||
|
||||
#### System Resource Issues
|
||||
```bash
|
||||
# Problem: Cache full or system overloaded (500)
|
||||
curl http://localhost:8000/videos/large_video.mp4/stream
|
||||
# Response: {"detail": "Failed to read video data"}
|
||||
# Solution: Clean cache or wait for system resources
|
||||
curl -X POST "http://localhost:8000/admin/videos/cache/cleanup?max_size_mb=50"
|
||||
```
|
||||
|
||||
### Debugging Workflow
|
||||
```bash
|
||||
# Step 1: Check system health
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Step 2: Verify video exists and get info
|
||||
curl http://localhost:8000/videos/your_video_id
|
||||
|
||||
# Step 3: Check streaming capabilities
|
||||
curl http://localhost:8000/videos/your_video_id/info
|
||||
|
||||
# Step 4: Validate video file
|
||||
curl -X POST http://localhost:8000/videos/your_video_id/validate
|
||||
|
||||
# Step 5: Test basic streaming
|
||||
curl -I http://localhost:8000/videos/your_video_id/stream
|
||||
|
||||
# Step 6: Test range request
|
||||
curl -I -H "Range: bytes=0-1023" http://localhost:8000/videos/your_video_id/stream
|
||||
```
|
||||
|
||||
### Performance Monitoring
|
||||
```bash
|
||||
# Monitor cache usage
|
||||
curl -X POST "http://localhost:8000/admin/videos/cache/cleanup?max_size_mb=100"
|
||||
|
||||
# Check system resources
|
||||
curl http://localhost:8000/system/status
|
||||
|
||||
# Monitor video module status
|
||||
curl http://localhost:8000/videos/ | jq '.total_count'
|
||||
```
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Restart the usda-vision-camera service** to enable video streaming
|
||||
2. **Test the endpoints** using curl or your browser
|
||||
3. **Integrate with your React app** using the provided examples
|
||||
4. **Monitor performance** and adjust caching as needed
|
||||
|
||||
The video streaming system is now ready for production use! 🚀
|
||||
302
web/API Documentations/docs/WEB_AI_AGENT_VIDEO_INTEGRATION.md
Normal file
302
web/API Documentations/docs/WEB_AI_AGENT_VIDEO_INTEGRATION.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# 🤖 Web AI Agent - Video Integration Guide
|
||||
|
||||
This guide provides the essential information for integrating USDA Vision Camera video streaming into your web application.
|
||||
|
||||
## 🎯 Quick Start
|
||||
|
||||
### Video Streaming Status: ✅ READY
|
||||
- **Progressive streaming implemented** - Videos play in browsers (no download)
|
||||
- **86 MP4 files available** - All properly indexed and streamable
|
||||
- **HTTP range requests supported** - Seeking and progressive playback work
|
||||
- **Memory efficient** - 8KB chunked delivery
|
||||
|
||||
## 🚀 API Endpoints
|
||||
|
||||
### Base URL
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
### 1. List Available Videos
|
||||
```http
|
||||
GET /videos/?camera_name={camera}&limit={limit}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl "http://localhost:8000/videos/?camera_name=camera1&limit=10"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"videos": [
|
||||
{
|
||||
"file_id": "camera1_auto_blower_separator_20250805_123329.mp4",
|
||||
"camera_name": "camera1",
|
||||
"file_size_bytes": 1072014489,
|
||||
"format": "mp4",
|
||||
"status": "completed",
|
||||
"is_streamable": true,
|
||||
"created_at": "2025-08-05T12:43:12.631210"
|
||||
}
|
||||
],
|
||||
"total_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Stream Video (Progressive)
|
||||
```http
|
||||
GET /videos/{file_id}/stream
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl "http://localhost:8000/videos/camera1_auto_blower_separator_20250805_123329.mp4/stream"
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- ✅ Progressive streaming (8KB chunks)
|
||||
- ✅ HTTP range requests (206 Partial Content)
|
||||
- ✅ Browser compatible (HTML5 video)
|
||||
- ✅ Seeking support
|
||||
- ✅ No authentication required
|
||||
|
||||
### 3. Get Video Thumbnail
|
||||
```http
|
||||
GET /videos/{file_id}/thumbnail?timestamp={seconds}&width={px}&height={px}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl "http://localhost:8000/videos/camera1_auto_blower_separator_20250805_123329.mp4/thumbnail?timestamp=5.0&width=320&height=240"
|
||||
```
|
||||
|
||||
## 🌐 Web Integration
|
||||
|
||||
### HTML5 Video Player
|
||||
```html
|
||||
<video controls width="100%" preload="metadata">
|
||||
<source src="http://localhost:8000/videos/{file_id}/stream" type="video/mp4">
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
```
|
||||
|
||||
### React Component
|
||||
```jsx
|
||||
function VideoPlayer({ fileId, width = "100%" }) {
|
||||
const streamUrl = `http://localhost:8000/videos/${fileId}/stream`;
|
||||
const thumbnailUrl = `http://localhost:8000/videos/${fileId}/thumbnail`;
|
||||
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
width={width}
|
||||
preload="metadata"
|
||||
poster={thumbnailUrl}
|
||||
style={{ maxWidth: '800px', borderRadius: '8px' }}
|
||||
>
|
||||
<source src={streamUrl} type="video/mp4" />
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Video List Component
|
||||
```jsx
|
||||
function VideoList({ cameraName = null, limit = 20 }) {
|
||||
const [videos, setVideos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (cameraName) params.append('camera_name', cameraName);
|
||||
params.append('limit', limit.toString());
|
||||
|
||||
fetch(`http://localhost:8000/videos/?${params}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Filter only streamable MP4 videos
|
||||
const streamableVideos = data.videos.filter(
|
||||
v => v.format === 'mp4' && v.is_streamable
|
||||
);
|
||||
setVideos(streamableVideos);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading videos:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [cameraName, limit]);
|
||||
|
||||
if (loading) return <div>Loading videos...</div>;
|
||||
|
||||
return (
|
||||
<div className="video-grid">
|
||||
{videos.map(video => (
|
||||
<div key={video.file_id} className="video-card">
|
||||
<h3>{video.file_id}</h3>
|
||||
<p>Camera: {video.camera_name}</p>
|
||||
<p>Size: {(video.file_size_bytes / 1024 / 1024).toFixed(1)} MB</p>
|
||||
<VideoPlayer fileId={video.file_id} width="100%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Available Data
|
||||
|
||||
### Current Video Inventory
|
||||
- **Total Videos**: 161 files
|
||||
- **MP4 Files**: 86 (all streamable ✅)
|
||||
- **AVI Files**: 75 (legacy format, not prioritized)
|
||||
- **Cameras**: camera1, camera2
|
||||
- **Date Range**: July 29 - August 5, 2025
|
||||
|
||||
### Video File Naming Convention
|
||||
```
|
||||
{camera}_{trigger}_{machine}_{YYYYMMDD}_{HHMMSS}.mp4
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- `camera1_auto_blower_separator_20250805_123329.mp4`
|
||||
- `camera2_auto_vibratory_conveyor_20250805_123042.mp4`
|
||||
- `20250804_161305_manual_camera1_2025-08-04T20-13-09-634Z.mp4`
|
||||
|
||||
### Machine Triggers
|
||||
- `auto_blower_separator` - Automatic recording triggered by blower separator
|
||||
- `auto_vibratory_conveyor` - Automatic recording triggered by vibratory conveyor
|
||||
- `manual` - Manual recording initiated by user
|
||||
|
||||
## 🔧 Technical Details
|
||||
|
||||
### Streaming Implementation
|
||||
- **Method**: FastAPI `StreamingResponse` with async generators
|
||||
- **Chunk Size**: 8KB for optimal performance
|
||||
- **Range Requests**: Full HTTP/1.1 range request support
|
||||
- **Status Codes**: 200 (full), 206 (partial), 404 (not found)
|
||||
- **CORS**: Enabled for all origins
|
||||
- **Caching**: Server-side byte-range caching
|
||||
|
||||
### Browser Compatibility
|
||||
- ✅ Chrome/Chromium
|
||||
- ✅ Firefox
|
||||
- ✅ Safari
|
||||
- ✅ Edge
|
||||
- ✅ Mobile browsers
|
||||
|
||||
### Performance Characteristics
|
||||
- **Memory Usage**: Low (8KB chunks, no large file loading)
|
||||
- **Seeking**: Instant (HTTP range requests)
|
||||
- **Startup Time**: Fast (metadata preload)
|
||||
- **Bandwidth**: Adaptive (only downloads viewed portions)
|
||||
|
||||
## 🛠️ Error Handling
|
||||
|
||||
### Common Scenarios
|
||||
```javascript
|
||||
// Check if video is streamable
|
||||
const checkVideo = async (fileId) => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8000/videos/${fileId}`);
|
||||
const video = await response.json();
|
||||
|
||||
if (!video.is_streamable) {
|
||||
console.warn(`Video ${fileId} is not streamable`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error checking video ${fileId}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle video loading errors
|
||||
const VideoPlayerWithErrorHandling = ({ fileId }) => {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleError = (e) => {
|
||||
console.error('Video playback error:', e);
|
||||
setError('Failed to load video. Please try again.');
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div className="error">❌ {error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
onError={handleError}
|
||||
src={`http://localhost:8000/videos/${fileId}/stream`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### HTTP Status Codes
|
||||
- `200 OK` - Video streaming successfully
|
||||
- `206 Partial Content` - Range request successful
|
||||
- `404 Not Found` - Video not found or not streamable
|
||||
- `416 Range Not Satisfiable` - Invalid range request
|
||||
- `500 Internal Server Error` - Server error reading video
|
||||
|
||||
## 🔐 Security Notes
|
||||
|
||||
### Current Configuration
|
||||
- **Authentication**: None (open access)
|
||||
- **CORS**: Enabled for all origins
|
||||
- **Network**: Designed for internal use
|
||||
- **HTTPS**: Not required (HTTP works)
|
||||
|
||||
### For Production Use
|
||||
Consider implementing:
|
||||
- Authentication/authorization
|
||||
- Rate limiting
|
||||
- HTTPS/TLS encryption
|
||||
- Network access controls
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Quick Test
|
||||
```bash
|
||||
# Test video listing
|
||||
curl "http://localhost:8000/videos/?limit=5"
|
||||
|
||||
# Test video streaming
|
||||
curl -I "http://localhost:8000/videos/camera1_auto_blower_separator_20250805_123329.mp4/stream"
|
||||
|
||||
# Test range request
|
||||
curl -H "Range: bytes=0-1023" "http://localhost:8000/videos/camera1_auto_blower_separator_20250805_123329.mp4/stream" -o test_chunk.mp4
|
||||
```
|
||||
|
||||
### Browser Test
|
||||
Open: `file:///home/alireza/USDA-vision-cameras/test_video_streaming.html`
|
||||
|
||||
## 📞 Support
|
||||
|
||||
### Service Management
|
||||
```bash
|
||||
# Restart video service
|
||||
sudo systemctl restart usda-vision-camera
|
||||
|
||||
# Check service status
|
||||
sudo systemctl status usda-vision-camera
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u usda-vision-camera -f
|
||||
```
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**✅ Ready for Integration**: The video streaming system is fully operational and ready for web application integration. All MP4 files are streamable with progressive playback support.
|
||||
521
web/API Documentations/docs/api/CAMERA_CONFIG_API.md
Normal file
521
web/API Documentations/docs/api/CAMERA_CONFIG_API.md
Normal file
@@ -0,0 +1,521 @@
|
||||
# 🎛️ Camera Configuration API Guide
|
||||
|
||||
This guide explains how to configure camera settings via API endpoints, including all the advanced settings from your config.json.
|
||||
|
||||
> **Note**: This document is part of the comprehensive [USDA Vision Camera System API Documentation](../API_DOCUMENTATION.md). For complete API reference, see the main documentation.
|
||||
|
||||
## 📋 Configuration Categories
|
||||
|
||||
### ✅ **Real-time Configurable (No Restart Required)**
|
||||
These settings can be changed while the camera is active:
|
||||
|
||||
- **Basic**: `exposure_ms`, `gain`, `target_fps`
|
||||
- **Image Quality**: `sharpness`, `contrast`, `saturation`, `gamma`
|
||||
- **Color**: `auto_white_balance`, `color_temperature_preset`
|
||||
- **White Balance**: `wb_red_gain`, `wb_green_gain`, `wb_blue_gain`
|
||||
- **Advanced**: `anti_flicker_enabled`, `light_frequency`
|
||||
- **HDR**: `hdr_enabled`, `hdr_gain_mode`
|
||||
|
||||
### ⚠️ **Restart Required**
|
||||
These settings require camera restart to take effect:
|
||||
|
||||
- **Noise Reduction**: `noise_filter_enabled`, `denoise_3d_enabled`
|
||||
- **Video Recording**: `video_format`, `video_codec`, `video_quality`
|
||||
- **System**: `machine_topic`, `storage_path`, `enabled`, `bit_depth`
|
||||
|
||||
### 🔒 **Read-Only Fields**
|
||||
These fields are returned in the response but cannot be modified via the API:
|
||||
|
||||
- **System Info**: `name`, `machine_topic`, `storage_path`, `enabled`
|
||||
- **Auto-Recording**: `auto_start_recording_enabled`, `auto_recording_max_retries`, `auto_recording_retry_delay_seconds`
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### 1. Get Camera Configuration
|
||||
```http
|
||||
GET /cameras/{camera_name}/config
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"name": "camera1",
|
||||
"machine_topic": "blower_separator",
|
||||
"storage_path": "/storage/camera1",
|
||||
"exposure_ms": 0.3,
|
||||
"gain": 4.0,
|
||||
"target_fps": 0,
|
||||
"enabled": true,
|
||||
"video_format": "mp4",
|
||||
"video_codec": "mp4v",
|
||||
"video_quality": 95,
|
||||
"auto_start_recording_enabled": true,
|
||||
"auto_recording_max_retries": 3,
|
||||
"auto_recording_retry_delay_seconds": 2,
|
||||
"contrast": 100,
|
||||
"saturation": 100,
|
||||
"gamma": 100,
|
||||
"noise_filter_enabled": false,
|
||||
"denoise_3d_enabled": false,
|
||||
"auto_white_balance": false,
|
||||
"color_temperature_preset": 0,
|
||||
"wb_red_gain": 0.94,
|
||||
"wb_green_gain": 1.0,
|
||||
"wb_blue_gain": 0.87,
|
||||
"anti_flicker_enabled": false,
|
||||
"light_frequency": 0,
|
||||
"bit_depth": 8,
|
||||
"hdr_enabled": false,
|
||||
"hdr_gain_mode": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Update Camera Configuration
|
||||
```http
|
||||
PUT /cameras/{camera_name}/config
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body (all fields optional):**
|
||||
```json
|
||||
{
|
||||
"exposure_ms": 2.0,
|
||||
"gain": 4.0,
|
||||
"target_fps": 10.0,
|
||||
"sharpness": 150,
|
||||
"contrast": 120,
|
||||
"saturation": 110,
|
||||
"gamma": 90,
|
||||
"noise_filter_enabled": true,
|
||||
"denoise_3d_enabled": false,
|
||||
"auto_white_balance": false,
|
||||
"color_temperature_preset": 1,
|
||||
"wb_red_gain": 1.2,
|
||||
"wb_green_gain": 1.0,
|
||||
"wb_blue_gain": 0.8,
|
||||
"anti_flicker_enabled": true,
|
||||
"light_frequency": 1,
|
||||
"hdr_enabled": false,
|
||||
"hdr_gain_mode": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Camera camera1 configuration updated",
|
||||
"updated_settings": ["exposure_ms", "gain", "sharpness", "wb_red_gain"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Apply Configuration (Restart Camera)
|
||||
```http
|
||||
POST /cameras/{camera_name}/apply-config
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Configuration applied to camera camera1"
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Setting Ranges and Descriptions
|
||||
|
||||
### System Settings
|
||||
| Setting | Values | Default | Description |
|
||||
|---------|--------|---------|-------------|
|
||||
| `name` | string | - | Camera identifier (read-only) |
|
||||
| `machine_topic` | string | - | MQTT topic for machine state (read-only) |
|
||||
| `storage_path` | string | - | Video storage directory (read-only) |
|
||||
| `enabled` | true/false | true | Camera enabled status (read-only) |
|
||||
|
||||
### Auto-Recording Settings
|
||||
| Setting | Range | Default | Description |
|
||||
|---------|-------|---------|-------------|
|
||||
| `auto_start_recording_enabled` | true/false | true | Enable automatic recording on machine state changes (read-only) |
|
||||
| `auto_recording_max_retries` | 1-10 | 3 | Maximum retry attempts for failed recordings (read-only) |
|
||||
| `auto_recording_retry_delay_seconds` | 1-30 | 2 | Delay between retry attempts in seconds (read-only) |
|
||||
|
||||
### Basic Settings
|
||||
| Setting | Range | Default | Description |
|
||||
|---------|-------|---------|-------------|
|
||||
| `exposure_ms` | 0.1 - 1000.0 | 1.0 | Exposure time in milliseconds |
|
||||
| `gain` | 0.0 - 20.0 | 3.5 | Camera gain multiplier |
|
||||
| `target_fps` | 0.0 - 120.0 | 0 | Target FPS (0 = maximum) |
|
||||
|
||||
### Image Quality Settings
|
||||
| Setting | Range | Default | Description |
|
||||
|---------|-------|---------|-------------|
|
||||
| `sharpness` | 0 - 200 | 100 | Image sharpness (100 = no sharpening) |
|
||||
| `contrast` | 0 - 200 | 100 | Image contrast (100 = normal) |
|
||||
| `saturation` | 0 - 200 | 100 | Color saturation (color cameras only) |
|
||||
| `gamma` | 0 - 300 | 100 | Gamma correction (100 = normal) |
|
||||
|
||||
### Color Settings
|
||||
| Setting | Values | Default | Description |
|
||||
|---------|--------|---------|-------------|
|
||||
| `auto_white_balance` | true/false | true | Automatic white balance |
|
||||
| `color_temperature_preset` | 0-10 | 0 | Color temperature preset (0=auto) |
|
||||
|
||||
### Manual White Balance RGB Gains
|
||||
| Setting | Range | Default | Description |
|
||||
|---------|-------|---------|-------------|
|
||||
| `wb_red_gain` | 0.0 - 3.99 | 1.0 | Red channel gain for manual white balance |
|
||||
| `wb_green_gain` | 0.0 - 3.99 | 1.0 | Green channel gain for manual white balance |
|
||||
| `wb_blue_gain` | 0.0 - 3.99 | 1.0 | Blue channel gain for manual white balance |
|
||||
|
||||
### Advanced Settings
|
||||
| Setting | Values | Default | Description |
|
||||
|---------|--------|---------|-------------|
|
||||
| `anti_flicker_enabled` | true/false | true | Reduce artificial lighting flicker |
|
||||
| `light_frequency` | 0/1 | 1 | Light frequency (0=50Hz, 1=60Hz) |
|
||||
| `noise_filter_enabled` | true/false | true | Basic noise filtering |
|
||||
| `denoise_3d_enabled` | true/false | false | Advanced 3D denoising |
|
||||
|
||||
### HDR Settings
|
||||
| Setting | Values | Default | Description |
|
||||
|---------|--------|---------|-------------|
|
||||
| `hdr_enabled` | true/false | false | High Dynamic Range |
|
||||
| `hdr_gain_mode` | 0-3 | 0 | HDR processing mode |
|
||||
|
||||
## 🚀 Usage Examples
|
||||
|
||||
### Example 1: Adjust Exposure and Gain
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"exposure_ms": 1.5,
|
||||
"gain": 4.0
|
||||
}'
|
||||
```
|
||||
|
||||
### Example 2: Improve Image Quality
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sharpness": 150,
|
||||
"contrast": 120,
|
||||
"gamma": 90
|
||||
}'
|
||||
```
|
||||
|
||||
### Example 3: Configure for Indoor Lighting
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"anti_flicker_enabled": true,
|
||||
"light_frequency": 1,
|
||||
"auto_white_balance": false,
|
||||
"color_temperature_preset": 2
|
||||
}'
|
||||
```
|
||||
|
||||
### Example 4: Enable HDR Mode
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"hdr_enabled": true,
|
||||
"hdr_gain_mode": 1
|
||||
}'
|
||||
```
|
||||
|
||||
## ⚛️ React Integration Examples
|
||||
|
||||
### Camera Configuration Component
|
||||
```jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const CameraConfig = ({ cameraName, apiBaseUrl = 'http://localhost:8000' }) => {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load current configuration
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [cameraName]);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/cameras/${cameraName}/config`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setConfig(data);
|
||||
} else {
|
||||
setError('Failed to load configuration');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const updateConfig = async (updates) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/cameras/${cameraName}/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('Updated settings:', result.updated_settings);
|
||||
await fetchConfig(); // Reload configuration
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setError(error.detail || 'Update failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Error: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSliderChange = (setting, value) => {
|
||||
updateConfig({ [setting]: value });
|
||||
};
|
||||
|
||||
if (!config) return <div>Loading configuration...</div>;
|
||||
|
||||
return (
|
||||
<div className="camera-config">
|
||||
<h3>Camera Configuration: {cameraName}</h3>
|
||||
|
||||
{/* System Information (Read-Only) */}
|
||||
<div className="config-section">
|
||||
<h4>System Information</h4>
|
||||
<div className="info-grid">
|
||||
<div><strong>Name:</strong> {config.name}</div>
|
||||
<div><strong>Machine Topic:</strong> {config.machine_topic}</div>
|
||||
<div><strong>Storage Path:</strong> {config.storage_path}</div>
|
||||
<div><strong>Enabled:</strong> {config.enabled ? 'Yes' : 'No'}</div>
|
||||
<div><strong>Auto Recording:</strong> {config.auto_start_recording_enabled ? 'Enabled' : 'Disabled'}</div>
|
||||
<div><strong>Max Retries:</strong> {config.auto_recording_max_retries}</div>
|
||||
<div><strong>Retry Delay:</strong> {config.auto_recording_retry_delay_seconds}s</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Settings */}
|
||||
<div className="config-section">
|
||||
<h4>Basic Settings</h4>
|
||||
|
||||
<div className="setting">
|
||||
<label>Exposure (ms): {config.exposure_ms}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={config.exposure_ms}
|
||||
onChange={(e) => handleSliderChange('exposure_ms', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting">
|
||||
<label>Gain: {config.gain}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={config.gain}
|
||||
onChange={(e) => handleSliderChange('gain', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting">
|
||||
<label>Target FPS: {config.target_fps}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="30"
|
||||
step="1"
|
||||
value={config.target_fps}
|
||||
onChange={(e) => handleSliderChange('target_fps', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Quality Settings */}
|
||||
<div className="config-section">
|
||||
<h4>Image Quality</h4>
|
||||
|
||||
<div className="setting">
|
||||
<label>Sharpness: {config.sharpness}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.sharpness}
|
||||
onChange={(e) => handleSliderChange('sharpness', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting">
|
||||
<label>Contrast: {config.contrast}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.contrast}
|
||||
onChange={(e) => handleSliderChange('contrast', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting">
|
||||
<label>Gamma: {config.gamma}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="300"
|
||||
value={config.gamma}
|
||||
onChange={(e) => handleSliderChange('gamma', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* White Balance RGB Gains */}
|
||||
<div className="config-section">
|
||||
<h4>White Balance RGB Gains</h4>
|
||||
|
||||
<div className="setting">
|
||||
<label>Red Gain: {config.wb_red_gain}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3.99"
|
||||
step="0.01"
|
||||
value={config.wb_red_gain}
|
||||
onChange={(e) => handleSliderChange('wb_red_gain', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting">
|
||||
<label>Green Gain: {config.wb_green_gain}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3.99"
|
||||
step="0.01"
|
||||
value={config.wb_green_gain}
|
||||
onChange={(e) => handleSliderChange('wb_green_gain', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting">
|
||||
<label>Blue Gain: {config.wb_blue_gain}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3.99"
|
||||
step="0.01"
|
||||
value={config.wb_blue_gain}
|
||||
onChange={(e) => handleSliderChange('wb_blue_gain', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<div className="config-section">
|
||||
<h4>Advanced Settings</h4>
|
||||
|
||||
<div className="setting">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.anti_flicker_enabled}
|
||||
onChange={(e) => updateConfig({ anti_flicker_enabled: e.target.checked })}
|
||||
/>
|
||||
Anti-flicker Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.auto_white_balance}
|
||||
onChange={(e) => updateConfig({ auto_white_balance: e.target.checked })}
|
||||
/>
|
||||
Auto White Balance
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hdr_enabled}
|
||||
onChange={(e) => updateConfig({ hdr_enabled: e.target.checked })}
|
||||
/>
|
||||
HDR Enabled
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error" style={{ color: 'red', marginTop: '10px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div>Updating configuration...</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraConfig;
|
||||
```
|
||||
|
||||
## 🔄 Configuration Workflow
|
||||
|
||||
### 1. Real-time Adjustments
|
||||
For settings that don't require restart:
|
||||
```bash
|
||||
# Update settings
|
||||
curl -X PUT /cameras/camera1/config -d '{"exposure_ms": 2.0}'
|
||||
|
||||
# Settings take effect immediately
|
||||
# Continue recording/streaming without interruption
|
||||
```
|
||||
|
||||
### 2. Settings Requiring Restart
|
||||
For noise reduction and system settings:
|
||||
```bash
|
||||
# Update settings
|
||||
curl -X PUT /cameras/camera1/config -d '{"noise_filter_enabled": false}'
|
||||
|
||||
# Apply configuration (restarts camera)
|
||||
curl -X POST /cameras/camera1/apply-config
|
||||
|
||||
# Camera reinitializes with new settings
|
||||
```
|
||||
|
||||
## 🚨 Important Notes
|
||||
|
||||
### Camera State During Updates
|
||||
- **Real-time settings**: Applied immediately, no interruption
|
||||
- **Restart-required settings**: Saved to config, applied on next restart
|
||||
- **Recording**: Continues during real-time updates
|
||||
- **Streaming**: Continues during real-time updates
|
||||
|
||||
### Error Handling
|
||||
- Invalid ranges return HTTP 422 with validation errors
|
||||
- Camera not found returns HTTP 404
|
||||
- SDK errors are logged and return HTTP 500
|
||||
|
||||
### Performance Impact
|
||||
- **Image quality settings**: Minimal performance impact
|
||||
- **Noise reduction**: May reduce FPS when enabled
|
||||
- **HDR**: Significant processing overhead when enabled
|
||||
|
||||
This comprehensive API allows you to control all camera settings programmatically, making it perfect for integration with React dashboards or automated optimization systems!
|
||||
127
web/API Documentations/docs/camera/BLOWER_CAMERA_CONFIG.md
Normal file
127
web/API Documentations/docs/camera/BLOWER_CAMERA_CONFIG.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Blower Camera (Camera1) Configuration
|
||||
|
||||
This document describes the default configuration for the blower camera (Camera1) based on the GigE camera settings from the dedicated software.
|
||||
|
||||
## Camera Identification
|
||||
- **Camera Name**: camera1 (Blower-Yield-Cam)
|
||||
- **Machine Topic**: blower_separator
|
||||
- **Purpose**: Monitors the blower separator machine
|
||||
|
||||
## Configuration Summary
|
||||
|
||||
Based on the camera settings screenshots, the following configuration has been applied to Camera1:
|
||||
|
||||
### Exposure Settings
|
||||
- **Mode**: Manual (not Auto)
|
||||
- **Exposure Time**: 1.0ms (1000μs)
|
||||
- **Gain**: 3.5x (350 in camera units)
|
||||
- **Anti-Flicker**: Enabled (50Hz mode)
|
||||
|
||||
### Color Processing Settings
|
||||
- **White Balance Mode**: Manual (not Auto)
|
||||
- **Color Temperature**: D65 (6500K)
|
||||
- **RGB Gain Values**:
|
||||
- Red Gain: 1.00
|
||||
- Green Gain: 1.00
|
||||
- Blue Gain: 1.00
|
||||
- **Saturation**: 100 (normal)
|
||||
|
||||
### LUT (Look-Up Table) Settings
|
||||
- **Mode**: Dynamically generated (not Preset or Custom)
|
||||
- **Gamma**: 1.00 (100 in config units)
|
||||
- **Contrast**: 100 (normal)
|
||||
|
||||
### Advanced Settings
|
||||
- **Anti-Flicker**: Enabled
|
||||
- **Light Frequency**: 60Hz (1 in config)
|
||||
- **Bit Depth**: 8-bit
|
||||
- **HDR**: Disabled
|
||||
|
||||
## Configuration Mapping
|
||||
|
||||
The screenshots show these key settings that have been mapped to the config.json:
|
||||
|
||||
| Screenshot Setting | Config Parameter | Value | Notes |
|
||||
|-------------------|------------------|-------|-------|
|
||||
| Manual Exposure | auto_exposure | false | Exposure mode set to manual |
|
||||
| Time(ms): 1.0000 | exposure_ms | 1.0 | Exposure time in milliseconds |
|
||||
| Gain(multiple): 3.500 | gain | 3.5 | Analog gain multiplier |
|
||||
| Manual White Balance | auto_white_balance | false | Manual WB mode |
|
||||
| Color Temperature: D65 | color_temperature_preset | 6500 | D65 = 6500K |
|
||||
| Red Gain: 1.00 | wb_red_gain | 1.0 | Manual RGB gain |
|
||||
| Green Gain: 1.00 | wb_green_gain | 1.0 | Manual RGB gain |
|
||||
| Blue Gain: 1.00 | wb_blue_gain | 1.0 | Manual RGB gain |
|
||||
| Saturation: 100 | saturation | 100 | Color saturation |
|
||||
| Gamma: 1.00 | gamma | 100 | Gamma correction |
|
||||
| Contrast: 100 | contrast | 100 | Image contrast |
|
||||
| 50HZ Anti-Flicker | anti_flicker_enabled | true | Flicker reduction |
|
||||
| 60Hz frequency | light_frequency | 1 | Power frequency |
|
||||
|
||||
## Current Configuration
|
||||
|
||||
The current config.json for camera1 includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "camera1",
|
||||
"machine_topic": "blower_separator",
|
||||
"storage_path": "/storage/camera1",
|
||||
"exposure_ms": 1.0,
|
||||
"gain": 3.5,
|
||||
"target_fps": 0,
|
||||
"enabled": true,
|
||||
"auto_start_recording_enabled": true,
|
||||
"auto_recording_max_retries": 3,
|
||||
"auto_recording_retry_delay_seconds": 2,
|
||||
"sharpness": 100,
|
||||
"contrast": 100,
|
||||
"saturation": 100,
|
||||
"gamma": 100,
|
||||
"noise_filter_enabled": false,
|
||||
"denoise_3d_enabled": false,
|
||||
"auto_white_balance": false,
|
||||
"color_temperature_preset": 6500,
|
||||
"anti_flicker_enabled": true,
|
||||
"light_frequency": 1,
|
||||
"bit_depth": 8,
|
||||
"hdr_enabled": false,
|
||||
"hdr_gain_mode": 0
|
||||
}
|
||||
```
|
||||
|
||||
## Camera Preview Enhancement
|
||||
|
||||
**Important Update**: The camera preview/streaming functionality has been enhanced to apply all default configuration settings from config.json, ensuring that preview images match the quality and appearance of recorded videos.
|
||||
|
||||
### What This Means for Camera1
|
||||
|
||||
When you view the camera preview, you'll now see:
|
||||
- **Manual exposure** (1.0ms) and **high gain** (3.5x) applied
|
||||
- **50Hz anti-flicker** filtering active
|
||||
- **Manual white balance** with balanced RGB gains (1.0, 1.0, 1.0)
|
||||
- **Standard image processing** (sharpness: 100, contrast: 100, gamma: 100, saturation: 100)
|
||||
- **D65 color temperature** (6500K) applied
|
||||
|
||||
This ensures the preview accurately represents what will be recorded.
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Machine Topic Correction**: The machine topic has been corrected from "vibratory_conveyor" to "blower_separator" to match the camera's actual monitoring purpose.
|
||||
|
||||
2. **Manual White Balance**: The camera is configured for manual white balance with D65 color temperature, which is appropriate for daylight conditions.
|
||||
|
||||
3. **RGB Gain Support**: The current configuration system needs to be extended to support individual RGB gain values for manual white balance fine-tuning.
|
||||
|
||||
4. **Anti-Flicker**: Enabled to reduce artificial lighting interference, set to 60Hz to match North American power frequency.
|
||||
|
||||
5. **LUT Mode**: The camera uses dynamically generated LUT with gamma=1.00 and contrast=100, which provides linear response.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
To fully support all settings shown in the screenshots, the following parameters should be added to the configuration system:
|
||||
|
||||
- `wb_red_gain`: Red channel gain for manual white balance (0.0-3.99)
|
||||
- `wb_green_gain`: Green channel gain for manual white balance (0.0-3.99)
|
||||
- `wb_blue_gain`: Blue channel gain for manual white balance (0.0-3.99)
|
||||
- `lut_mode`: LUT generation mode (0=dynamic, 1=preset, 2=custom)
|
||||
- `lut_preset`: Preset LUT selection when using preset mode
|
||||
150
web/API Documentations/docs/camera/CONVEYOR_CAMERA_CONFIG.md
Normal file
150
web/API Documentations/docs/camera/CONVEYOR_CAMERA_CONFIG.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Conveyor Camera (Camera2) Configuration
|
||||
|
||||
This document describes the default configuration for the conveyor camera (Camera2) based on the GigE camera settings from the dedicated software.
|
||||
|
||||
## Camera Identification
|
||||
- **Camera Name**: camera2 (Cracker-Cam)
|
||||
- **Machine Topic**: vibratory_conveyor
|
||||
- **Purpose**: Monitors the vibratory conveyor/cracker machine
|
||||
|
||||
## Configuration Summary
|
||||
|
||||
Based on the camera settings screenshots, the following configuration has been applied to Camera2:
|
||||
|
||||
### Color Processing Settings
|
||||
- **White Balance Mode**: Manual (not Auto)
|
||||
- **Color Temperature**: D65 (6500K)
|
||||
- **RGB Gain Values**:
|
||||
- Red Gain: 1.01
|
||||
- Green Gain: 1.00
|
||||
- Blue Gain: 0.87
|
||||
- **Saturation**: 100 (normal)
|
||||
|
||||
### LUT (Look-Up Table) Settings
|
||||
- **Mode**: Dynamically generated (not Preset or Custom)
|
||||
- **Gamma**: 1.00 (100 in config units)
|
||||
- **Contrast**: 100 (normal)
|
||||
|
||||
### Graphic Processing Settings
|
||||
- **Sharpness Level**: 0 (no sharpening applied)
|
||||
- **Noise Reduction**:
|
||||
- Denoise2D: Disabled
|
||||
- Denoise3D: Disabled
|
||||
- **Rotation**: Disabled
|
||||
- **Lens Distortion Correction**: Disabled
|
||||
- **Dead Pixel Correction**: Enabled
|
||||
- **Flat Fielding Correction**: Disabled
|
||||
|
||||
## Configuration Mapping
|
||||
|
||||
The screenshots show these key settings that have been mapped to the config.json:
|
||||
|
||||
| Screenshot Setting | Config Parameter | Value | Notes |
|
||||
|-------------------|------------------|-------|-------|
|
||||
| Manual White Balance | auto_white_balance | false | Manual WB mode |
|
||||
| Color Temperature: D65 | color_temperature_preset | 6500 | D65 = 6500K |
|
||||
| Red Gain: 1.01 | wb_red_gain | 1.01 | Manual RGB gain |
|
||||
| Green Gain: 1.00 | wb_green_gain | 1.0 | Manual RGB gain |
|
||||
| Blue Gain: 0.87 | wb_blue_gain | 0.87 | Manual RGB gain |
|
||||
| Saturation: 100 | saturation | 100 | Color saturation |
|
||||
| Gamma: 1.00 | gamma | 100 | Gamma correction |
|
||||
| Contrast: 100 | contrast | 100 | Image contrast |
|
||||
| Sharpen Level: 0 | sharpness | 0 | No sharpening |
|
||||
| Denoise2D: Disabled | noise_filter_enabled | false | Basic noise filter off |
|
||||
| Denoise3D: Disable | denoise_3d_enabled | false | Advanced denoising off |
|
||||
|
||||
## Current Configuration
|
||||
|
||||
The current config.json for camera2 includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "camera2",
|
||||
"machine_topic": "vibratory_conveyor",
|
||||
"storage_path": "/storage/camera2",
|
||||
"exposure_ms": 0.5,
|
||||
"gain": 0.3,
|
||||
"target_fps": 0,
|
||||
"enabled": true,
|
||||
"auto_start_recording_enabled": true,
|
||||
"auto_recording_max_retries": 3,
|
||||
"auto_recording_retry_delay_seconds": 2,
|
||||
"sharpness": 0,
|
||||
"contrast": 100,
|
||||
"saturation": 100,
|
||||
"gamma": 100,
|
||||
"noise_filter_enabled": false,
|
||||
"denoise_3d_enabled": false,
|
||||
"auto_white_balance": false,
|
||||
"color_temperature_preset": 6500,
|
||||
"wb_red_gain": 1.01,
|
||||
"wb_green_gain": 1.0,
|
||||
"wb_blue_gain": 0.87,
|
||||
"anti_flicker_enabled": false,
|
||||
"light_frequency": 1,
|
||||
"bit_depth": 8,
|
||||
"hdr_enabled": false,
|
||||
"hdr_gain_mode": 0
|
||||
}
|
||||
```
|
||||
|
||||
## Key Differences from Camera1 (Blower Camera)
|
||||
|
||||
1. **RGB Gain Tuning**: Camera2 has custom RGB gains (R:1.01, G:1.00, B:0.87) vs Camera1's balanced gains (all 1.0)
|
||||
2. **Sharpness**: Camera2 has sharpness disabled (0) vs Camera1's normal sharpness (100)
|
||||
3. **Exposure/Gain**: Camera2 uses lower exposure (0.5ms) and gain (0.3x) vs Camera1's higher values (1.0ms, 3.5x)
|
||||
4. **Anti-Flicker**: Camera2 has anti-flicker disabled vs Camera1's enabled anti-flicker
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Custom White Balance**: Camera2 uses manual white balance with custom RGB gains, suggesting specific lighting conditions or color correction requirements for the conveyor monitoring.
|
||||
|
||||
2. **No Sharpening**: Sharpness is set to 0, indicating the raw image quality is preferred without artificial enhancement.
|
||||
|
||||
3. **Minimal Noise Reduction**: Both 2D and 3D denoising are disabled, prioritizing image authenticity over noise reduction.
|
||||
|
||||
4. **Dead Pixel Correction**: Enabled to handle any defective pixels on the sensor.
|
||||
|
||||
5. **Lower Sensitivity**: The lower exposure and gain settings suggest better lighting conditions or different monitoring requirements compared to the blower camera.
|
||||
|
||||
## Camera Preview Enhancement
|
||||
|
||||
**Important Update**: The camera preview/streaming functionality has been enhanced to apply all default configuration settings from config.json, ensuring that preview images match the quality and appearance of recorded videos.
|
||||
|
||||
### What Changed
|
||||
|
||||
Previously, camera preview only applied basic settings (exposure, gain, trigger mode). Now, the preview applies the complete configuration including:
|
||||
|
||||
- **Image Quality**: Sharpness, contrast, gamma, saturation
|
||||
- **Color Processing**: White balance mode, color temperature, RGB gains
|
||||
- **Advanced Settings**: Anti-flicker, light frequency, HDR settings
|
||||
- **Noise Reduction**: Filter and 3D denoising settings (where supported)
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **WYSIWYG Preview**: What you see in the preview is exactly what gets recorded
|
||||
2. **Accurate Color Representation**: Manual white balance and RGB gains are applied to preview
|
||||
3. **Consistent Image Quality**: Sharpness, contrast, and gamma settings match recording
|
||||
4. **Proper Exposure**: Anti-flicker and lighting frequency settings are applied
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
The `CameraStreamer` class now includes the same comprehensive configuration methods as `CameraRecorder`:
|
||||
|
||||
- `_configure_image_quality()`: Applies sharpness, contrast, gamma, saturation
|
||||
- `_configure_color_settings()`: Applies white balance mode, color temperature, RGB gains
|
||||
- `_configure_advanced_settings()`: Applies anti-flicker, light frequency, HDR
|
||||
- `_configure_noise_reduction()`: Applies noise filter settings
|
||||
|
||||
These methods are called during camera initialization for streaming, ensuring all config.json settings are applied.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Additional parameters that could be added to support all graphic processing features:
|
||||
|
||||
- `rotation_angle`: Image rotation (0, 90, 180, 270 degrees)
|
||||
- `lens_distortion_correction`: Enable/disable lens distortion correction
|
||||
- `dead_pixel_correction`: Enable/disable dead pixel correction
|
||||
- `flat_fielding_correction`: Enable/disable flat fielding correction
|
||||
- `mirror_horizontal`: Horizontal mirroring
|
||||
- `mirror_vertical`: Vertical mirroring
|
||||
159
web/API Documentations/docs/camera/PREVIEW_ENHANCEMENT.md
Normal file
159
web/API Documentations/docs/camera/PREVIEW_ENHANCEMENT.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Camera Preview Enhancement
|
||||
|
||||
## Overview
|
||||
|
||||
The camera preview/streaming functionality has been significantly enhanced to apply all default configuration settings from `config.json`, ensuring that preview images accurately represent what will be recorded.
|
||||
|
||||
## Problem Solved
|
||||
|
||||
Previously, camera preview only applied basic settings (exposure, gain, trigger mode, frame rate), while recording applied the full configuration. This meant:
|
||||
|
||||
- Preview images looked different from recorded videos
|
||||
- Color balance, sharpness, and other image quality settings were not visible in preview
|
||||
- Users couldn't accurately assess the final recording quality from the preview
|
||||
|
||||
## Solution Implemented
|
||||
|
||||
The `CameraStreamer` class has been enhanced with comprehensive configuration methods that mirror those in `CameraRecorder`:
|
||||
|
||||
### New Configuration Methods Added
|
||||
|
||||
1. **`_configure_image_quality()`**
|
||||
- Applies sharpness settings (0-200)
|
||||
- Applies contrast settings (0-200)
|
||||
- Applies gamma correction (0-300)
|
||||
- Applies saturation for color cameras (0-200)
|
||||
|
||||
2. **`_configure_color_settings()`**
|
||||
- Sets white balance mode (auto/manual)
|
||||
- Applies color temperature presets
|
||||
- Sets manual RGB gains for precise color tuning
|
||||
|
||||
3. **`_configure_advanced_settings()`**
|
||||
- Enables/disables anti-flicker filtering
|
||||
- Sets light frequency (50Hz/60Hz)
|
||||
- Configures HDR settings when available
|
||||
|
||||
4. **`_configure_noise_reduction()`**
|
||||
- Configures noise filter settings
|
||||
- Configures 3D denoising settings
|
||||
|
||||
### Enhanced Main Configuration Method
|
||||
|
||||
The `_configure_streaming_settings()` method now calls all configuration methods:
|
||||
|
||||
```python
|
||||
def _configure_streaming_settings(self):
|
||||
"""Configure camera settings from config.json for streaming"""
|
||||
try:
|
||||
# Basic settings (existing)
|
||||
mvsdk.CameraSetTriggerMode(self.hCamera, 0)
|
||||
mvsdk.CameraSetAeState(self.hCamera, 0)
|
||||
exposure_us = int(self.camera_config.exposure_ms * 1000)
|
||||
mvsdk.CameraSetExposureTime(self.hCamera, exposure_us)
|
||||
gain_value = int(self.camera_config.gain * 100)
|
||||
mvsdk.CameraSetAnalogGain(self.hCamera, gain_value)
|
||||
|
||||
# Comprehensive configuration (new)
|
||||
self._configure_image_quality()
|
||||
self._configure_noise_reduction()
|
||||
if not self.monoCamera:
|
||||
self._configure_color_settings()
|
||||
self._configure_advanced_settings()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not configure some streaming settings: {e}")
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. WYSIWYG Preview
|
||||
- **What You See Is What You Get**: Preview now accurately represents final recording quality
|
||||
- **Real-time Assessment**: Users can evaluate recording quality before starting actual recording
|
||||
- **Consistent Experience**: No surprises when comparing preview to recorded footage
|
||||
|
||||
### 2. Accurate Color Representation
|
||||
- **Manual White Balance**: RGB gains are applied to preview for accurate color reproduction
|
||||
- **Color Temperature**: D65 or other presets are applied consistently
|
||||
- **Saturation**: Color intensity matches recording settings
|
||||
|
||||
### 3. Proper Image Quality
|
||||
- **Sharpness**: Edge enhancement settings are visible in preview
|
||||
- **Contrast**: Dynamic range adjustments are applied
|
||||
- **Gamma**: Brightness curve corrections are active
|
||||
|
||||
### 4. Environmental Adaptation
|
||||
- **Anti-Flicker**: Artificial lighting interference is filtered in preview
|
||||
- **Light Frequency**: 50Hz/60Hz settings match local power grid
|
||||
- **HDR**: High dynamic range processing when enabled
|
||||
|
||||
## Camera-Specific Impact
|
||||
|
||||
### Camera1 (Blower Separator)
|
||||
Preview now shows:
|
||||
- Manual exposure (1.0ms) and high gain (3.5x)
|
||||
- 50Hz anti-flicker filtering
|
||||
- Manual white balance with balanced RGB gains (1.0, 1.0, 1.0)
|
||||
- Standard image processing (sharpness: 100, contrast: 100, gamma: 100, saturation: 100)
|
||||
- D65 color temperature (6500K)
|
||||
|
||||
### Camera2 (Conveyor/Cracker)
|
||||
Preview now shows:
|
||||
- Manual exposure (0.5ms) and lower gain (0.3x)
|
||||
- Custom RGB color tuning (R:1.01, G:1.00, B:0.87)
|
||||
- No image sharpening (sharpness: 0)
|
||||
- Enhanced saturation (100) and proper gamma (100)
|
||||
- D65 color temperature with manual white balance
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Error Handling
|
||||
- All configuration methods include try-catch blocks
|
||||
- Warnings are logged for unsupported features
|
||||
- Graceful degradation when SDK functions are unavailable
|
||||
- Streaming continues even if some settings fail to apply
|
||||
|
||||
### SDK Compatibility
|
||||
- Checks for function availability before calling
|
||||
- Handles different SDK versions gracefully
|
||||
- Logs informational messages for unavailable features
|
||||
|
||||
### Performance Considerations
|
||||
- Configuration is applied once during camera initialization
|
||||
- No performance impact on streaming frame rate
|
||||
- Separate camera instance for streaming (doesn't interfere with recording)
|
||||
|
||||
## Usage
|
||||
|
||||
No changes required for users - the enhancement is automatic:
|
||||
|
||||
1. **Start Preview**: Use existing preview endpoints
|
||||
2. **View Stream**: Camera automatically applies all config.json settings
|
||||
3. **Compare**: Preview now matches recording quality exactly
|
||||
|
||||
### API Endpoints (unchanged)
|
||||
- `GET /cameras/{camera_name}/stream` - Get live MJPEG stream
|
||||
- `POST /cameras/{camera_name}/start-stream` - Start streaming
|
||||
- `POST /cameras/{camera_name}/stop-stream` - Stop streaming
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Additional settings that could be added to further improve preview accuracy:
|
||||
|
||||
1. **Geometric Corrections**
|
||||
- Lens distortion correction
|
||||
- Dead pixel correction
|
||||
- Flat fielding correction
|
||||
|
||||
2. **Image Transformations**
|
||||
- Rotation (90°, 180°, 270°)
|
||||
- Horizontal/vertical mirroring
|
||||
|
||||
3. **Advanced Processing**
|
||||
- Custom LUT (Look-Up Table) support
|
||||
- Advanced noise reduction algorithms
|
||||
- Real-time image enhancement filters
|
||||
|
||||
## Conclusion
|
||||
|
||||
This enhancement significantly improves the user experience by providing accurate, real-time preview of camera output with all configuration settings applied. Users can now confidently assess recording quality, adjust settings, and ensure optimal camera performance before starting critical recordings.
|
||||
@@ -0,0 +1,262 @@
|
||||
# Auto-Recording Feature Implementation Guide
|
||||
|
||||
## 🎯 Overview for React App Development
|
||||
|
||||
This document provides a comprehensive guide for updating the React application to support the new auto-recording feature that was added to the USDA Vision Camera System.
|
||||
|
||||
> **📚 For complete API reference**: See the [USDA Vision Camera System API Documentation](../API_DOCUMENTATION.md) for detailed endpoint specifications and examples.
|
||||
|
||||
## 📋 What Changed in the Backend
|
||||
|
||||
### New API Endpoints Added
|
||||
|
||||
1. **Enable Auto-Recording**
|
||||
```http
|
||||
POST /cameras/{camera_name}/auto-recording/enable
|
||||
Response: AutoRecordingConfigResponse
|
||||
```
|
||||
|
||||
2. **Disable Auto-Recording**
|
||||
```http
|
||||
POST /cameras/{camera_name}/auto-recording/disable
|
||||
Response: AutoRecordingConfigResponse
|
||||
```
|
||||
|
||||
3. **Get Auto-Recording Status**
|
||||
```http
|
||||
GET /auto-recording/status
|
||||
Response: AutoRecordingStatusResponse
|
||||
```
|
||||
|
||||
### Updated API Responses
|
||||
|
||||
#### CameraStatusResponse (Updated)
|
||||
```typescript
|
||||
interface CameraStatusResponse {
|
||||
name: string;
|
||||
status: string;
|
||||
is_recording: boolean;
|
||||
last_checked: string;
|
||||
last_error?: string;
|
||||
device_info?: any;
|
||||
current_recording_file?: string;
|
||||
recording_start_time?: string;
|
||||
|
||||
// NEW AUTO-RECORDING FIELDS
|
||||
auto_recording_enabled: boolean;
|
||||
auto_recording_active: boolean;
|
||||
auto_recording_failure_count: number;
|
||||
auto_recording_last_attempt?: string;
|
||||
auto_recording_last_error?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### CameraConfigResponse (Updated)
|
||||
```typescript
|
||||
interface CameraConfigResponse {
|
||||
name: string;
|
||||
machine_topic: string;
|
||||
storage_path: string;
|
||||
enabled: boolean;
|
||||
|
||||
// NEW AUTO-RECORDING CONFIG FIELDS
|
||||
auto_start_recording_enabled: boolean;
|
||||
auto_recording_max_retries: number;
|
||||
auto_recording_retry_delay_seconds: number;
|
||||
|
||||
// ... existing fields (exposure_ms, gain, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
#### New Response Types
|
||||
```typescript
|
||||
interface AutoRecordingConfigResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
camera_name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface AutoRecordingStatusResponse {
|
||||
running: boolean;
|
||||
auto_recording_enabled: boolean;
|
||||
retry_queue: Record<string, any>;
|
||||
enabled_cameras: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 React App UI Requirements
|
||||
|
||||
### 1. Camera Status Display Updates
|
||||
|
||||
**Add to Camera Cards/Components:**
|
||||
- Auto-recording enabled/disabled indicator
|
||||
- Auto-recording active status (when machine is ON and auto-recording)
|
||||
- Failure count display (if > 0)
|
||||
- Last auto-recording error (if any)
|
||||
- Visual distinction between manual and auto-recording
|
||||
|
||||
**Example UI Elements:**
|
||||
```jsx
|
||||
// Auto-recording status badge
|
||||
{camera.auto_recording_enabled && (
|
||||
<Badge variant={camera.auto_recording_active ? "success" : "secondary"}>
|
||||
Auto-Recording {camera.auto_recording_active ? "Active" : "Enabled"}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
// Failure indicator
|
||||
{camera.auto_recording_failure_count > 0 && (
|
||||
<Alert variant="warning">
|
||||
Auto-recording failures: {camera.auto_recording_failure_count}
|
||||
</Alert>
|
||||
)}
|
||||
```
|
||||
|
||||
### 2. Auto-Recording Controls
|
||||
|
||||
**Add Toggle Controls:**
|
||||
- Enable/Disable auto-recording per camera
|
||||
- Global auto-recording status display
|
||||
- Retry queue monitoring
|
||||
|
||||
**Example Control Component:**
|
||||
```jsx
|
||||
const AutoRecordingToggle = ({ camera, onToggle }) => {
|
||||
const handleToggle = async () => {
|
||||
const endpoint = camera.auto_recording_enabled ? 'disable' : 'enable';
|
||||
await fetch(`/cameras/${camera.name}/auto-recording/${endpoint}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
onToggle();
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={camera.auto_recording_enabled}
|
||||
onChange={handleToggle}
|
||||
label="Auto-Recording"
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Machine State Integration
|
||||
|
||||
**Display Machine Status:**
|
||||
- Show which machine each camera monitors
|
||||
- Display current machine state (ON/OFF)
|
||||
- Show correlation between machine state and recording status
|
||||
|
||||
**Camera-Machine Mapping:**
|
||||
- Camera 1 → Vibratory Conveyor (conveyor/cracker cam)
|
||||
- Camera 2 → Blower Separator (blower separator)
|
||||
|
||||
### 4. Auto-Recording Dashboard
|
||||
|
||||
**Create New Dashboard Section:**
|
||||
- Overall auto-recording system status
|
||||
- List of cameras with auto-recording enabled
|
||||
- Active retry queue display
|
||||
- Recent auto-recording events/logs
|
||||
|
||||
## 🔧 Implementation Steps for React App
|
||||
|
||||
### Step 1: Update TypeScript Interfaces
|
||||
```typescript
|
||||
// Update existing interfaces in your types file
|
||||
// Add new interfaces for auto-recording responses
|
||||
```
|
||||
|
||||
### Step 2: Update API Service Functions
|
||||
```typescript
|
||||
// Add new API calls
|
||||
export const enableAutoRecording = (cameraName: string) =>
|
||||
fetch(`/cameras/${cameraName}/auto-recording/enable`, { method: 'POST' });
|
||||
|
||||
export const disableAutoRecording = (cameraName: string) =>
|
||||
fetch(`/cameras/${cameraName}/auto-recording/disable`, { method: 'POST' });
|
||||
|
||||
export const getAutoRecordingStatus = () =>
|
||||
fetch('/auto-recording/status').then(res => res.json());
|
||||
```
|
||||
|
||||
### Step 3: Update Camera Components
|
||||
- Add auto-recording status indicators
|
||||
- Add enable/disable controls
|
||||
- Update recording status display to distinguish auto vs manual
|
||||
|
||||
### Step 4: Create Auto-Recording Management Panel
|
||||
- System-wide auto-recording status
|
||||
- Per-camera auto-recording controls
|
||||
- Retry queue monitoring
|
||||
- Error reporting and alerts
|
||||
|
||||
### Step 5: Update State Management
|
||||
```typescript
|
||||
// Add auto-recording state to your store/context
|
||||
interface AppState {
|
||||
cameras: CameraStatusResponse[];
|
||||
autoRecordingStatus: AutoRecordingStatusResponse;
|
||||
// ... existing state
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Key User Experience Considerations
|
||||
|
||||
### Visual Indicators
|
||||
1. **Recording Status Hierarchy:**
|
||||
- Manual Recording (highest priority - red/prominent)
|
||||
- Auto-Recording Active (green/secondary)
|
||||
- Auto-Recording Enabled but Inactive (blue/subtle)
|
||||
- Auto-Recording Disabled (gray/muted)
|
||||
|
||||
2. **Machine State Correlation:**
|
||||
- Show machine ON/OFF status next to camera
|
||||
- Indicate when auto-recording should be active
|
||||
- Alert if machine is ON but auto-recording failed
|
||||
|
||||
3. **Error Handling:**
|
||||
- Clear error messages for auto-recording failures
|
||||
- Retry count display
|
||||
- Last attempt timestamp
|
||||
- Quick retry/reset options
|
||||
|
||||
### User Controls
|
||||
1. **Quick Actions:**
|
||||
- Toggle auto-recording per camera
|
||||
- Force retry failed auto-recording
|
||||
- Override auto-recording (manual control)
|
||||
|
||||
2. **Configuration:**
|
||||
- Adjust retry settings
|
||||
- Change machine-camera mappings
|
||||
- Set recording parameters for auto-recording
|
||||
|
||||
## 🚨 Important Notes
|
||||
|
||||
### Behavior Rules
|
||||
1. **Manual Override:** Manual recording always takes precedence over auto-recording
|
||||
2. **Non-Blocking:** Auto-recording status checks don't interfere with camera operation
|
||||
3. **Machine Correlation:** Auto-recording only activates when the associated machine turns ON
|
||||
4. **Failure Handling:** Failed auto-recording attempts are retried automatically with exponential backoff
|
||||
|
||||
### API Polling Recommendations
|
||||
- Poll camera status every 2-3 seconds for real-time updates
|
||||
- Poll auto-recording status every 5-10 seconds
|
||||
- Use WebSocket connections if available for real-time machine state updates
|
||||
|
||||
## 📱 Mobile Considerations
|
||||
- Auto-recording controls should be easily accessible on mobile
|
||||
- Status indicators should be clear and readable on small screens
|
||||
- Consider collapsible sections for detailed auto-recording information
|
||||
|
||||
## 🔍 Testing Checklist
|
||||
- [ ] Auto-recording toggle works for each camera
|
||||
- [ ] Status updates reflect machine state changes
|
||||
- [ ] Error states are clearly displayed
|
||||
- [ ] Manual recording overrides auto-recording
|
||||
- [ ] Retry mechanism is visible to users
|
||||
- [ ] Mobile interface is functional
|
||||
|
||||
This guide provides everything needed to update the React app to fully support the new auto-recording feature!
|
||||
158
web/API Documentations/docs/guides/CAMERA_RECOVERY_GUIDE.md
Normal file
158
web/API Documentations/docs/guides/CAMERA_RECOVERY_GUIDE.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Camera Recovery and Diagnostics Guide
|
||||
|
||||
This guide explains the new camera recovery functionality implemented in the USDA Vision Camera System API.
|
||||
|
||||
## Overview
|
||||
|
||||
The system now includes comprehensive camera recovery capabilities to handle connection issues, initialization failures, and other camera-related problems. These features use the underlying mvsdk (python demo) library functions to perform various recovery operations.
|
||||
|
||||
## Available Recovery Operations
|
||||
|
||||
### 1. Connection Test (`/cameras/{camera_name}/test-connection`)
|
||||
- **Purpose**: Test if the camera connection is working
|
||||
- **SDK Function**: `CameraConnectTest()`
|
||||
- **Use Case**: Diagnose connection issues
|
||||
- **HTTP Method**: POST
|
||||
- **Response**: `CameraTestResponse`
|
||||
|
||||
### 2. Reconnect (`/cameras/{camera_name}/reconnect`)
|
||||
- **Purpose**: Soft reconnection to the camera
|
||||
- **SDK Function**: `CameraReConnect()`
|
||||
- **Use Case**: Most common fix for connection issues
|
||||
- **HTTP Method**: POST
|
||||
- **Response**: `CameraRecoveryResponse`
|
||||
|
||||
### 3. Restart Grab (`/cameras/{camera_name}/restart-grab`)
|
||||
- **Purpose**: Restart the camera grab process
|
||||
- **SDK Function**: `CameraRestartGrab()`
|
||||
- **Use Case**: Fix issues with image capture
|
||||
- **HTTP Method**: POST
|
||||
- **Response**: `CameraRecoveryResponse`
|
||||
|
||||
### 4. Reset Timestamp (`/cameras/{camera_name}/reset-timestamp`)
|
||||
- **Purpose**: Reset camera timestamp
|
||||
- **SDK Function**: `CameraRstTimeStamp()`
|
||||
- **Use Case**: Fix timing-related issues
|
||||
- **HTTP Method**: POST
|
||||
- **Response**: `CameraRecoveryResponse`
|
||||
|
||||
### 5. Full Reset (`/cameras/{camera_name}/full-reset`)
|
||||
- **Purpose**: Complete camera reset (uninitialize and reinitialize)
|
||||
- **SDK Functions**: `CameraUnInit()` + `CameraInit()`
|
||||
- **Use Case**: Hard reset for persistent issues
|
||||
- **HTTP Method**: POST
|
||||
- **Response**: `CameraRecoveryResponse`
|
||||
|
||||
### 6. Reinitialize (`/cameras/{camera_name}/reinitialize`)
|
||||
- **Purpose**: Reinitialize cameras that failed initial setup
|
||||
- **SDK Functions**: Complete recorder recreation
|
||||
- **Use Case**: Cameras that never initialized properly
|
||||
- **HTTP Method**: POST
|
||||
- **Response**: `CameraRecoveryResponse`
|
||||
|
||||
## Recommended Troubleshooting Workflow
|
||||
|
||||
When a camera has issues, follow this order:
|
||||
|
||||
1. **Test Connection** - Diagnose the problem
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/test-connection
|
||||
```
|
||||
|
||||
2. **Try Reconnect** - Most common fix
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/reconnect
|
||||
```
|
||||
|
||||
3. **Restart Grab** - If reconnect doesn't work
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/restart-grab
|
||||
```
|
||||
|
||||
4. **Full Reset** - For persistent issues
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/full-reset
|
||||
```
|
||||
|
||||
5. **Reinitialize** - For cameras that never worked
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/reinitialize
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
All recovery operations return structured responses:
|
||||
|
||||
### CameraTestResponse
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Camera camera1 connection test passed",
|
||||
"camera_name": "camera1",
|
||||
"timestamp": "2024-01-01T12:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### CameraRecoveryResponse
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Camera camera1 reconnected successfully",
|
||||
"camera_name": "camera1",
|
||||
"operation": "reconnect",
|
||||
"timestamp": "2024-01-01T12:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### CameraRecorder Methods
|
||||
- `test_connection()`: Tests camera connection
|
||||
- `reconnect()`: Performs soft reconnection
|
||||
- `restart_grab()`: Restarts grab process
|
||||
- `reset_timestamp()`: Resets timestamp
|
||||
- `full_reset()`: Complete reset with cleanup and reinitialization
|
||||
|
||||
### CameraManager Methods
|
||||
- `test_camera_connection(camera_name)`: Test specific camera
|
||||
- `reconnect_camera(camera_name)`: Reconnect specific camera
|
||||
- `restart_camera_grab(camera_name)`: Restart grab for specific camera
|
||||
- `reset_camera_timestamp(camera_name)`: Reset timestamp for specific camera
|
||||
- `full_reset_camera(camera_name)`: Full reset for specific camera
|
||||
- `reinitialize_failed_camera(camera_name)`: Reinitialize failed camera
|
||||
|
||||
### State Management
|
||||
All recovery operations automatically update the camera status in the state manager:
|
||||
- Success: Status set to "connected"
|
||||
- Failure: Status set to appropriate error state with error message
|
||||
|
||||
## Error Handling
|
||||
|
||||
The system includes comprehensive error handling:
|
||||
- SDK exceptions are caught and logged
|
||||
- State manager is updated with error information
|
||||
- Proper HTTP status codes are returned
|
||||
- Detailed error messages are provided
|
||||
|
||||
## Testing
|
||||
|
||||
Use the provided test files:
|
||||
- `api-tests.http`: Manual API testing with VS Code REST Client
|
||||
- `test_camera_recovery_api.py`: Automated testing script
|
||||
|
||||
## Safety Features
|
||||
|
||||
- Recording is automatically stopped before recovery operations
|
||||
- Camera resources are properly cleaned up
|
||||
- Thread-safe operations with proper locking
|
||||
- Graceful error handling prevents system crashes
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
1. **Camera Lost Connection**: Use reconnect
|
||||
2. **Camera Won't Capture**: Use restart-grab
|
||||
3. **Camera Initialization Failed**: Use reinitialize
|
||||
4. **Persistent Issues**: Use full-reset
|
||||
5. **Timing Problems**: Use reset-timestamp
|
||||
|
||||
This recovery system provides robust tools to handle most camera-related issues without requiring system restart or manual intervention.
|
||||
187
web/API Documentations/docs/guides/MQTT_LOGGING_GUIDE.md
Normal file
187
web/API Documentations/docs/guides/MQTT_LOGGING_GUIDE.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# MQTT Console Logging & API Guide
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
Your USDA Vision Camera System now has **enhanced MQTT console logging** and **comprehensive API endpoints** for monitoring machine status via MQTT.
|
||||
|
||||
## ✨ What's New
|
||||
|
||||
### 1. **Enhanced Console Logging**
|
||||
- **Colorful emoji-based console output** for all MQTT events
|
||||
- **Real-time visibility** of MQTT connections, subscriptions, and messages
|
||||
- **Clear status indicators** for debugging and monitoring
|
||||
|
||||
### 2. **New MQTT Status API Endpoint**
|
||||
- **GET /mqtt/status** - Detailed MQTT client statistics
|
||||
- **Message counts, error tracking, uptime monitoring**
|
||||
- **Real-time connection status and broker information**
|
||||
|
||||
### 3. **Existing Machine Status APIs** (already available)
|
||||
- **GET /machines** - All machine states from MQTT
|
||||
- **GET /system/status** - Overall system status including MQTT
|
||||
|
||||
## 🖥️ Console Logging Examples
|
||||
|
||||
When you run the system, you'll see:
|
||||
|
||||
```bash
|
||||
🔗 MQTT CONNECTED: 192.168.1.110:1883
|
||||
📋 MQTT SUBSCRIBED: vibratory_conveyor → vision/vibratory_conveyor/state
|
||||
📋 MQTT SUBSCRIBED: blower_separator → vision/blower_separator/state
|
||||
📡 MQTT MESSAGE: vibratory_conveyor → on
|
||||
📡 MQTT MESSAGE: blower_separator → off
|
||||
⚠️ MQTT DISCONNECTED: Unexpected disconnection (code: 1)
|
||||
🔗 MQTT CONNECTED: 192.168.1.110:1883
|
||||
```
|
||||
|
||||
## 🌐 API Endpoints
|
||||
|
||||
### MQTT Status
|
||||
```http
|
||||
GET http://localhost:8000/mqtt/status
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"connected": true,
|
||||
"broker_host": "192.168.1.110",
|
||||
"broker_port": 1883,
|
||||
"subscribed_topics": [
|
||||
"vision/vibratory_conveyor/state",
|
||||
"vision/blower_separator/state"
|
||||
],
|
||||
"last_message_time": "2025-07-28T12:00:00",
|
||||
"message_count": 42,
|
||||
"error_count": 0,
|
||||
"uptime_seconds": 3600.5
|
||||
}
|
||||
```
|
||||
|
||||
### Machine Status
|
||||
```http
|
||||
GET http://localhost:8000/machines
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"vibratory_conveyor": {
|
||||
"name": "vibratory_conveyor",
|
||||
"state": "on",
|
||||
"last_updated": "2025-07-28T12:00:00",
|
||||
"last_message": "on",
|
||||
"mqtt_topic": "vision/vibratory_conveyor/state"
|
||||
},
|
||||
"blower_separator": {
|
||||
"name": "blower_separator",
|
||||
"state": "off",
|
||||
"last_updated": "2025-07-28T12:00:00",
|
||||
"last_message": "off",
|
||||
"mqtt_topic": "vision/blower_separator/state"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### System Status
|
||||
```http
|
||||
GET http://localhost:8000/system/status
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"system_started": true,
|
||||
"mqtt_connected": true,
|
||||
"last_mqtt_message": "2025-07-28T12:00:00",
|
||||
"machines": { ... },
|
||||
"cameras": { ... },
|
||||
"active_recordings": 0,
|
||||
"total_recordings": 5,
|
||||
"uptime_seconds": 3600.5
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 How to Use
|
||||
|
||||
### 1. **Start the Full System**
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
You'll see enhanced console logging for all MQTT events.
|
||||
|
||||
### 2. **Test MQTT Demo (MQTT only)**
|
||||
```bash
|
||||
python demo_mqtt_console.py
|
||||
```
|
||||
Shows just the MQTT client with enhanced logging.
|
||||
|
||||
### 3. **Test API Endpoints**
|
||||
```bash
|
||||
python test_mqtt_logging.py
|
||||
```
|
||||
Tests all the API endpoints and shows expected responses.
|
||||
|
||||
### 4. **Query APIs Directly**
|
||||
```bash
|
||||
# Check MQTT status
|
||||
curl http://localhost:8000/mqtt/status
|
||||
|
||||
# Check machine states
|
||||
curl http://localhost:8000/machines
|
||||
|
||||
# Check overall system status
|
||||
curl http://localhost:8000/system/status
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
The MQTT settings are in `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mqtt": {
|
||||
"broker_host": "192.168.1.110",
|
||||
"broker_port": 1883,
|
||||
"username": null,
|
||||
"password": null,
|
||||
"topics": {
|
||||
"vibratory_conveyor": "vision/vibratory_conveyor/state",
|
||||
"blower_separator": "vision/blower_separator/state"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 Console Output Features
|
||||
|
||||
- **🔗 Connection Events**: Green for successful connections
|
||||
- **📋 Subscriptions**: Blue for topic subscriptions
|
||||
- **📡 Messages**: Real-time message display with machine name and payload
|
||||
- **⚠️ Warnings**: Yellow for unexpected disconnections
|
||||
- **❌ Errors**: Red for connection failures and errors
|
||||
- **❓ Unknown Topics**: Purple for unrecognized MQTT topics
|
||||
|
||||
## 📊 Monitoring & Debugging
|
||||
|
||||
### Real-time Monitoring
|
||||
- **Console**: Watch live MQTT events as they happen
|
||||
- **API**: Query `/mqtt/status` for statistics and health
|
||||
- **Logs**: Check `usda_vision_system.log` for detailed logs
|
||||
|
||||
### Troubleshooting
|
||||
1. **No MQTT messages?** Check broker connectivity and topic configuration
|
||||
2. **Connection issues?** Verify broker host/port in config.json
|
||||
3. **API not responding?** Ensure the system is running with `python main.py`
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
1. **Development**: See MQTT messages in real-time while developing
|
||||
2. **Debugging**: Identify connection issues and message patterns
|
||||
3. **Monitoring**: Use APIs to build dashboards or monitoring tools
|
||||
4. **Integration**: Query machine states from external applications
|
||||
5. **Maintenance**: Track MQTT statistics and error rates
|
||||
|
||||
---
|
||||
|
||||
**🎉 Your MQTT monitoring is now fully enhanced with both console logging and comprehensive APIs!**
|
||||
240
web/API Documentations/docs/guides/STREAMING_GUIDE.md
Normal file
240
web/API Documentations/docs/guides/STREAMING_GUIDE.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# 🎥 USDA Vision Camera Live Streaming Guide
|
||||
|
||||
This guide explains how to use the new live preview streaming functionality that allows you to view camera feeds in real-time without blocking recording operations.
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
- **Non-blocking streaming**: Live preview doesn't interfere with recording
|
||||
- **Separate camera connections**: Streaming uses independent camera instances
|
||||
- **MJPEG streaming**: Standard web-compatible video streaming
|
||||
- **Multiple concurrent viewers**: Multiple browsers can view the same stream
|
||||
- **REST API control**: Start/stop streaming via API endpoints
|
||||
- **Web interface**: Ready-to-use HTML interface for live preview
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The streaming system creates separate camera connections for preview that are independent from recording:
|
||||
|
||||
```
|
||||
Camera Hardware
|
||||
├── Recording Connection (CameraRecorder)
|
||||
│ ├── Used for video file recording
|
||||
│ ├── Triggered by MQTT machine states
|
||||
│ └── High quality, full FPS
|
||||
└── Streaming Connection (CameraStreamer)
|
||||
├── Used for live preview
|
||||
├── Controlled via API endpoints
|
||||
└── Optimized for web viewing (lower FPS, JPEG compression)
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Start the System
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 2. Open the Web Interface
|
||||
Open `camera_preview.html` in your browser and click "Start Stream" for any camera.
|
||||
|
||||
### 3. API Usage
|
||||
```bash
|
||||
# Start streaming for camera1
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-stream
|
||||
|
||||
# View live stream (open in browser)
|
||||
http://localhost:8000/cameras/camera1/stream
|
||||
|
||||
# Stop streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-stream
|
||||
```
|
||||
|
||||
## 📡 API Endpoints
|
||||
|
||||
### Start Streaming
|
||||
```http
|
||||
POST /cameras/{camera_name}/start-stream
|
||||
```
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Started streaming for camera camera1"
|
||||
}
|
||||
```
|
||||
|
||||
### Stop Streaming
|
||||
```http
|
||||
POST /cameras/{camera_name}/stop-stream
|
||||
```
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Stopped streaming for camera camera1"
|
||||
}
|
||||
```
|
||||
|
||||
### Live Stream (MJPEG)
|
||||
```http
|
||||
GET /cameras/{camera_name}/stream
|
||||
```
|
||||
**Response:** Multipart MJPEG stream
|
||||
**Content-Type:** `multipart/x-mixed-replace; boundary=frame`
|
||||
|
||||
## 🌐 Web Interface Usage
|
||||
|
||||
The included `camera_preview.html` provides a complete web interface:
|
||||
|
||||
1. **Camera Grid**: Shows all configured cameras
|
||||
2. **Stream Controls**: Start/Stop/Refresh buttons for each camera
|
||||
3. **Live Preview**: Real-time video feed display
|
||||
4. **Status Information**: System and camera status
|
||||
5. **Responsive Design**: Works on desktop and mobile
|
||||
|
||||
### Features:
|
||||
- ✅ Real-time camera status
|
||||
- ✅ One-click stream start/stop
|
||||
- ✅ Automatic stream refresh
|
||||
- ✅ System health monitoring
|
||||
- ✅ Error handling and status messages
|
||||
|
||||
## 🔧 Technical Details
|
||||
|
||||
### Camera Streamer Configuration
|
||||
- **Preview FPS**: 10 FPS (configurable)
|
||||
- **JPEG Quality**: 70% (configurable)
|
||||
- **Frame Buffer**: 5 frames (prevents memory buildup)
|
||||
- **Timeout**: 200ms per frame capture
|
||||
|
||||
### Memory Management
|
||||
- Automatic frame buffer cleanup
|
||||
- Queue-based frame management
|
||||
- Proper camera resource cleanup on stop
|
||||
|
||||
### Thread Safety
|
||||
- Thread-safe streaming operations
|
||||
- Independent from recording threads
|
||||
- Proper synchronization with locks
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Run the Test Script
|
||||
```bash
|
||||
python test_streaming.py
|
||||
```
|
||||
|
||||
This will test:
|
||||
- ✅ API endpoint functionality
|
||||
- ✅ Stream start/stop operations
|
||||
- ✅ Concurrent recording and streaming
|
||||
- ✅ Error handling
|
||||
|
||||
### Manual Testing
|
||||
1. Start the system: `python main.py`
|
||||
2. Open `camera_preview.html` in browser
|
||||
3. Start streaming for a camera
|
||||
4. Trigger recording via MQTT or manual API
|
||||
5. Verify both work simultaneously
|
||||
|
||||
## 🔄 Concurrent Operations
|
||||
|
||||
The system supports these concurrent operations:
|
||||
|
||||
| Operation | Recording | Streaming | Notes |
|
||||
|-----------|-----------|-----------|-------|
|
||||
| Recording Only | ✅ | ❌ | Normal operation |
|
||||
| Streaming Only | ❌ | ✅ | Preview without recording |
|
||||
| Both Concurrent | ✅ | ✅ | **Independent connections** |
|
||||
|
||||
### Example: Concurrent Usage
|
||||
```bash
|
||||
# Start streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-stream
|
||||
|
||||
# Start recording (while streaming continues)
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"filename": "test_recording.avi"}'
|
||||
|
||||
# Both operations run independently!
|
||||
```
|
||||
|
||||
## 🛠️ Configuration
|
||||
|
||||
### Stream Settings (in CameraStreamer)
|
||||
```python
|
||||
self.preview_fps = 10.0 # Lower FPS for preview
|
||||
self.preview_quality = 70 # JPEG quality (1-100)
|
||||
self._frame_queue.maxsize = 5 # Frame buffer size
|
||||
```
|
||||
|
||||
### Camera Settings
|
||||
The streamer uses the same camera configuration as recording:
|
||||
- Exposure time from `camera_config.exposure_ms`
|
||||
- Gain from `camera_config.gain`
|
||||
- Optimized trigger mode for continuous streaming
|
||||
|
||||
## 🚨 Important Notes
|
||||
|
||||
### Camera Access Patterns
|
||||
- **Recording**: Blocks camera during active recording
|
||||
- **Streaming**: Uses separate connection, doesn't block
|
||||
- **Health Checks**: Brief, non-blocking camera tests
|
||||
- **Multiple Streams**: Multiple browsers can view same stream
|
||||
|
||||
### Performance Considerations
|
||||
- Streaming uses additional CPU/memory resources
|
||||
- Lower preview FPS reduces system load
|
||||
- JPEG compression reduces bandwidth usage
|
||||
- Frame queue prevents memory buildup
|
||||
|
||||
### Error Handling
|
||||
- Automatic camera resource cleanup
|
||||
- Graceful handling of camera disconnections
|
||||
- Stream auto-restart capabilities
|
||||
- Detailed error logging
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Stream Not Starting
|
||||
1. Check camera availability: `GET /cameras`
|
||||
2. Verify camera not in error state
|
||||
3. Check system logs for camera initialization errors
|
||||
4. Try camera reconnection: `POST /cameras/{name}/reconnect`
|
||||
|
||||
### Poor Stream Quality
|
||||
1. Adjust `preview_quality` setting (higher = better quality)
|
||||
2. Increase `preview_fps` for smoother video
|
||||
3. Check network bandwidth
|
||||
4. Verify camera exposure/gain settings
|
||||
|
||||
### Browser Issues
|
||||
1. Try different browser (Chrome/Firefox recommended)
|
||||
2. Check browser console for JavaScript errors
|
||||
3. Verify CORS settings in API server
|
||||
4. Clear browser cache and refresh
|
||||
|
||||
## 📈 Future Enhancements
|
||||
|
||||
Potential improvements for the streaming system:
|
||||
|
||||
- 🔄 WebRTC support for lower latency
|
||||
- 📱 Mobile app integration
|
||||
- 🎛️ Real-time camera setting adjustments
|
||||
- 📊 Stream analytics and monitoring
|
||||
- 🔐 Authentication and access control
|
||||
- 🌐 Multi-camera synchronized viewing
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues with streaming functionality:
|
||||
|
||||
1. Check the system logs: `usda_vision_system.log`
|
||||
2. Run the test script: `python test_streaming.py`
|
||||
3. Verify API health: `http://localhost:8000/health`
|
||||
4. Check camera status: `http://localhost:8000/cameras`
|
||||
|
||||
---
|
||||
|
||||
**✅ Live streaming is now ready for production use!**
|
||||
146
web/API Documentations/docs/legacy/01README.md
Normal file
146
web/API Documentations/docs/legacy/01README.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# GigE Camera Image Capture
|
||||
|
||||
This project provides simple Python scripts to connect to a GigE camera and capture images using the provided SDK.
|
||||
|
||||
## Files Overview
|
||||
|
||||
### Demo Files (provided with camera)
|
||||
- `python demo/mvsdk.py` - Main SDK wrapper library
|
||||
- `python demo/grab.py` - Basic image capture example
|
||||
- `python demo/cv_grab.py` - OpenCV-based continuous capture
|
||||
- `python demo/cv_grab_callback.py` - Callback-based capture
|
||||
- `python demo/readme.txt` - Original demo documentation
|
||||
|
||||
### Custom Scripts
|
||||
- `camera_capture.py` - Standalone script to capture 10 images with 200ms intervals
|
||||
- `test.ipynb` - Jupyter notebook with the same functionality
|
||||
- `images/` - Directory where captured images are saved
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic camera detection** - Finds and connects to available GigE cameras
|
||||
- **Configurable capture** - Currently set to capture 10 images with 200ms intervals
|
||||
- **Both mono and color support** - Automatically detects camera type
|
||||
- **Timestamped filenames** - Images saved with date/time stamps
|
||||
- **Error handling** - Robust error handling for camera operations
|
||||
- **Cross-platform** - Works on Windows and Linux (with appropriate image flipping)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.x
|
||||
- OpenCV (`cv2`)
|
||||
- NumPy
|
||||
- Matplotlib (for Jupyter notebook display)
|
||||
- GigE camera SDK (MVSDK) - included in `python demo/` directory
|
||||
|
||||
## Usage
|
||||
|
||||
### Option 1: Standalone Script
|
||||
|
||||
Run the standalone Python script:
|
||||
|
||||
```bash
|
||||
python camera_capture.py
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Initialize the camera SDK
|
||||
2. Detect available cameras
|
||||
3. Connect to the first camera found
|
||||
4. Configure camera settings (manual exposure, continuous mode)
|
||||
5. Capture 10 images with 200ms intervals
|
||||
6. Save images to the `images/` directory
|
||||
7. Clean up and close the camera
|
||||
|
||||
### Option 2: Jupyter Notebook
|
||||
|
||||
Open and run the `test.ipynb` notebook:
|
||||
|
||||
```bash
|
||||
jupyter notebook test.ipynb
|
||||
```
|
||||
|
||||
The notebook provides the same functionality but with:
|
||||
- Step-by-step execution
|
||||
- Detailed explanations
|
||||
- Visual display of the last captured image
|
||||
- Better error reporting
|
||||
|
||||
## Camera Configuration
|
||||
|
||||
The scripts are configured with the following default settings:
|
||||
|
||||
- **Trigger Mode**: Continuous capture (mode 0)
|
||||
- **Exposure**: Manual, 30ms
|
||||
- **Output Format**:
|
||||
- Monochrome cameras: MONO8
|
||||
- Color cameras: BGR8
|
||||
- **Image Processing**: Automatic ISP processing from RAW to RGB/MONO
|
||||
|
||||
## Output
|
||||
|
||||
Images are saved in the `images/` directory with the following naming convention:
|
||||
```
|
||||
image_XX_YYYYMMDD_HHMMSS_mmm.jpg
|
||||
```
|
||||
|
||||
Where:
|
||||
- `XX` = Image number (01-10)
|
||||
- `YYYYMMDD_HHMMSS_mmm` = Timestamp with milliseconds
|
||||
|
||||
Example: `image_01_20250722_140530_123.jpg`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"No camera was found!"**
|
||||
- Check camera connection (Ethernet cable)
|
||||
- Verify camera power
|
||||
- Check network settings (camera and PC should be on same subnet)
|
||||
- Ensure camera drivers are installed
|
||||
|
||||
2. **"CameraInit Failed"**
|
||||
- Camera might be in use by another application
|
||||
- Check camera permissions
|
||||
- Try restarting the camera or PC
|
||||
|
||||
3. **"Failed to capture image"**
|
||||
- Check camera settings
|
||||
- Verify sufficient lighting
|
||||
- Check exposure settings
|
||||
|
||||
4. **Images appear upside down**
|
||||
- This is handled automatically on Windows
|
||||
- Linux users may need to adjust the flip settings
|
||||
|
||||
### Network Configuration
|
||||
|
||||
For GigE cameras, ensure:
|
||||
- Camera and PC are on the same network segment
|
||||
- PC network adapter supports Jumbo frames (recommended)
|
||||
- Firewall allows camera communication
|
||||
- Sufficient network bandwidth
|
||||
|
||||
## Customization
|
||||
|
||||
You can modify the scripts to:
|
||||
|
||||
- **Change capture count**: Modify the range in the capture loop
|
||||
- **Adjust timing**: Change the `time.sleep(0.2)` value
|
||||
- **Modify exposure**: Change the exposure time parameter
|
||||
- **Change output format**: Modify file format and quality settings
|
||||
- **Add image processing**: Insert processing steps before saving
|
||||
|
||||
## SDK Reference
|
||||
|
||||
The camera SDK (`mvsdk.py`) provides extensive functionality:
|
||||
|
||||
- Camera enumeration and initialization
|
||||
- Image capture and processing
|
||||
- Parameter configuration (exposure, gain, etc.)
|
||||
- Trigger modes and timing
|
||||
- Image format conversion
|
||||
- Error handling
|
||||
|
||||
Refer to the original SDK documentation for advanced features.
|
||||
184
web/API Documentations/docs/legacy/IMPLEMENTATION_SUMMARY.md
Normal file
184
web/API Documentations/docs/legacy/IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# USDA Vision Camera System - Implementation Summary
|
||||
|
||||
## 🎉 Project Completed Successfully!
|
||||
|
||||
The USDA Vision Camera System has been fully implemented and tested. All components are working correctly and the system is ready for deployment.
|
||||
|
||||
## ✅ What Was Built
|
||||
|
||||
### Core Architecture
|
||||
- **Modular Design**: Clean separation of concerns across multiple modules
|
||||
- **Multi-threading**: Concurrent MQTT listening, camera monitoring, and recording
|
||||
- **Event-driven**: Thread-safe communication between components
|
||||
- **Configuration-driven**: JSON-based configuration system
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **MQTT Integration** (`usda_vision_system/mqtt/`)
|
||||
- Listens to two machine topics: `vision/vibratory_conveyor/state` and `vision/blower_separator/state`
|
||||
- Thread-safe message handling with automatic reconnection
|
||||
- State normalization (on/off/error)
|
||||
|
||||
2. **Camera Management** (`usda_vision_system/camera/`)
|
||||
- Automatic GigE camera discovery using python demo library
|
||||
- Periodic status monitoring (every 2 seconds)
|
||||
- Camera initialization and configuration management
|
||||
- **Discovered Cameras**:
|
||||
- Blower-Yield-Cam (192.168.1.165)
|
||||
- Cracker-Cam (192.168.1.167)
|
||||
|
||||
3. **Video Recording** (`usda_vision_system/camera/recorder.py`)
|
||||
- Automatic recording start/stop based on machine states
|
||||
- Timestamp-based file naming: `camera1_recording_20250726_143022.avi`
|
||||
- Configurable FPS, exposure, and gain settings
|
||||
- Thread-safe recording with proper cleanup
|
||||
|
||||
4. **Storage Management** (`usda_vision_system/storage/`)
|
||||
- Organized file storage under `./storage/camera1/` and `./storage/camera2/`
|
||||
- File indexing and metadata tracking
|
||||
- Automatic cleanup of old files
|
||||
- Storage statistics and integrity checking
|
||||
|
||||
5. **REST API Server** (`usda_vision_system/api/`)
|
||||
- FastAPI server on port 8000
|
||||
- Real-time WebSocket updates
|
||||
- Manual recording control endpoints
|
||||
- System status and monitoring endpoints
|
||||
|
||||
6. **Comprehensive Logging** (`usda_vision_system/core/logging_config.py`)
|
||||
- Colored console output
|
||||
- Rotating log files
|
||||
- Component-specific log levels
|
||||
- Performance monitoring and error tracking
|
||||
|
||||
## 🚀 How to Use
|
||||
|
||||
### Quick Start
|
||||
```bash
|
||||
# Run system tests
|
||||
python test_system.py
|
||||
|
||||
# Start the system
|
||||
python main.py
|
||||
|
||||
# Or use the startup script
|
||||
./start_system.sh
|
||||
```
|
||||
|
||||
### Configuration
|
||||
Edit `config.json` to customize:
|
||||
- MQTT broker settings
|
||||
- Camera configurations
|
||||
- Storage paths
|
||||
- System parameters
|
||||
|
||||
### API Access
|
||||
- System status: `http://localhost:8000/system/status`
|
||||
- Camera status: `http://localhost:8000/cameras`
|
||||
- Manual recording: `POST http://localhost:8000/cameras/camera1/start-recording`
|
||||
- Real-time updates: WebSocket at `ws://localhost:8000/ws`
|
||||
|
||||
## 📊 Test Results
|
||||
|
||||
All system tests passed successfully:
|
||||
- ✅ Module imports
|
||||
- ✅ Configuration loading
|
||||
- ✅ Camera discovery (found 2 cameras)
|
||||
- ✅ Storage setup
|
||||
- ✅ MQTT configuration
|
||||
- ✅ System initialization
|
||||
- ✅ API endpoints
|
||||
|
||||
## 🔧 System Behavior
|
||||
|
||||
### Automatic Recording Flow
|
||||
1. **Machine turns ON** → MQTT message received → Recording starts automatically
|
||||
2. **Machine turns OFF** → MQTT message received → Recording stops and saves file
|
||||
3. **Files saved** with timestamp: `camera1_recording_YYYYMMDD_HHMMSS.avi`
|
||||
|
||||
### Manual Control
|
||||
- Start/stop recording via API calls
|
||||
- Monitor system status in real-time
|
||||
- Check camera availability on demand
|
||||
|
||||
### Dashboard Integration
|
||||
The system is designed to integrate with your React + Vite + Tailwind + Supabase dashboard:
|
||||
- REST API for status queries
|
||||
- WebSocket for real-time updates
|
||||
- JSON responses for easy frontend consumption
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
usda_vision_system/
|
||||
├── core/ # Configuration, state management, events, logging
|
||||
├── mqtt/ # MQTT client and message handlers
|
||||
├── camera/ # Camera management, monitoring, recording
|
||||
├── storage/ # File organization and management
|
||||
├── api/ # FastAPI server and WebSocket support
|
||||
└── main.py # Application coordinator
|
||||
|
||||
Supporting Files:
|
||||
├── main.py # Entry point script
|
||||
├── config.json # System configuration
|
||||
├── test_system.py # Test suite
|
||||
├── start_system.sh # Startup script
|
||||
└── README_SYSTEM.md # Comprehensive documentation
|
||||
```
|
||||
|
||||
## 🎯 Key Features Delivered
|
||||
|
||||
- ✅ **Dual MQTT topic listening** for two machines
|
||||
- ✅ **Automatic camera recording** triggered by machine states
|
||||
- ✅ **GigE camera support** using python demo library
|
||||
- ✅ **Thread-safe multi-tasking** (MQTT + camera monitoring + recording)
|
||||
- ✅ **Timestamp-based file naming** in organized directories
|
||||
- ✅ **2-second camera status monitoring** with on-demand checks
|
||||
- ✅ **REST API and WebSocket** for dashboard integration
|
||||
- ✅ **Comprehensive logging** with error tracking
|
||||
- ✅ **Configuration management** via JSON
|
||||
- ✅ **Storage management** with cleanup capabilities
|
||||
- ✅ **Graceful startup/shutdown** with signal handling
|
||||
|
||||
## 🔮 Ready for Dashboard Integration
|
||||
|
||||
The system provides everything needed for your React dashboard:
|
||||
|
||||
```javascript
|
||||
// Example API usage
|
||||
const systemStatus = await fetch('http://localhost:8000/system/status');
|
||||
const cameras = await fetch('http://localhost:8000/cameras');
|
||||
|
||||
// WebSocket for real-time updates
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
// Handle real-time system updates
|
||||
};
|
||||
|
||||
// Manual recording control
|
||||
await fetch('http://localhost:8000/cameras/camera1/start-recording', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ camera_name: 'camera1' })
|
||||
});
|
||||
```
|
||||
|
||||
## 🎊 Next Steps
|
||||
|
||||
The system is production-ready! You can now:
|
||||
|
||||
1. **Deploy** the system on your target hardware
|
||||
2. **Integrate** with your existing React dashboard
|
||||
3. **Configure** MQTT topics and camera settings as needed
|
||||
4. **Monitor** system performance through logs and API endpoints
|
||||
5. **Extend** functionality as requirements evolve
|
||||
|
||||
The modular architecture makes it easy to add new features, cameras, or MQTT topics in the future.
|
||||
|
||||
---
|
||||
|
||||
**System Status**: ✅ **FULLY OPERATIONAL**
|
||||
**Test Results**: ✅ **ALL TESTS PASSING**
|
||||
**Cameras Detected**: ✅ **2 GIGE CAMERAS READY**
|
||||
**Ready for Production**: ✅ **YES**
|
||||
1
web/API Documentations/docs/legacy/README.md
Normal file
1
web/API Documentations/docs/legacy/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# USDA-Vision-Cameras
|
||||
249
web/API Documentations/docs/legacy/README_SYSTEM.md
Normal file
249
web/API Documentations/docs/legacy/README_SYSTEM.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# USDA Vision Camera System
|
||||
|
||||
A comprehensive system for monitoring machines via MQTT and automatically recording video from GigE cameras when machines are active.
|
||||
|
||||
## Overview
|
||||
|
||||
This system integrates MQTT machine monitoring with automated video recording from GigE cameras. When a machine turns on (detected via MQTT), the system automatically starts recording from the associated camera. When the machine turns off, recording stops and the video is saved with a timestamp.
|
||||
|
||||
## Features
|
||||
|
||||
- **MQTT Integration**: Listens to multiple machine state topics
|
||||
- **Automatic Recording**: Starts/stops recording based on machine states
|
||||
- **GigE Camera Support**: Uses the python demo library (mvsdk) for camera control
|
||||
- **Multi-threading**: Concurrent MQTT listening, camera monitoring, and recording
|
||||
- **REST API**: FastAPI server for dashboard integration
|
||||
- **WebSocket Support**: Real-time status updates
|
||||
- **Storage Management**: Organized file storage with cleanup capabilities
|
||||
- **Comprehensive Logging**: Detailed logging with rotation and error tracking
|
||||
- **Configuration Management**: JSON-based configuration system
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ MQTT Broker │ │ GigE Camera │ │ Dashboard │
|
||||
│ │ │ │ │ (React) │
|
||||
└─────────┬───────┘ └─────────┬───────┘ └─────────┬───────┘
|
||||
│ │ │
|
||||
│ Machine States │ Video Streams │ API Calls
|
||||
│ │ │
|
||||
┌─────────▼──────────────────────▼──────────────────────▼───────┐
|
||||
│ USDA Vision Camera System │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ MQTT Client │ │ Camera │ │ API Server │ │
|
||||
│ │ │ │ Manager │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ State │ │ Storage │ │ Event │ │
|
||||
│ │ Manager │ │ Manager │ │ System │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Prerequisites**:
|
||||
- Python 3.11+
|
||||
- GigE cameras with python demo library
|
||||
- MQTT broker (e.g., Mosquitto)
|
||||
- uv package manager (recommended)
|
||||
|
||||
2. **Install Dependencies**:
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
3. **Setup Storage Directory**:
|
||||
```bash
|
||||
sudo mkdir -p /storage
|
||||
sudo chown $USER:$USER /storage
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `config.json` to configure your system:
|
||||
|
||||
```json
|
||||
{
|
||||
"mqtt": {
|
||||
"broker_host": "192.168.1.110",
|
||||
"broker_port": 1883,
|
||||
"topics": {
|
||||
"vibratory_conveyor": "vision/vibratory_conveyor/state",
|
||||
"blower_separator": "vision/blower_separator/state"
|
||||
}
|
||||
},
|
||||
"cameras": [
|
||||
{
|
||||
"name": "camera1",
|
||||
"machine_topic": "vibratory_conveyor",
|
||||
"storage_path": "/storage/camera1",
|
||||
"exposure_ms": 1.0,
|
||||
"gain": 3.5,
|
||||
"target_fps": 3.0,
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
1. **Start the System**:
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
2. **With Custom Config**:
|
||||
```bash
|
||||
python main.py --config my_config.json
|
||||
```
|
||||
|
||||
3. **Debug Mode**:
|
||||
```bash
|
||||
python main.py --log-level DEBUG
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
The system provides a REST API on port 8000:
|
||||
|
||||
- `GET /system/status` - Overall system status
|
||||
- `GET /cameras` - All camera statuses
|
||||
- `GET /machines` - All machine states
|
||||
- `POST /cameras/{name}/start-recording` - Manual recording start
|
||||
- `POST /cameras/{name}/stop-recording` - Manual recording stop
|
||||
- `GET /storage/stats` - Storage statistics
|
||||
- `WebSocket /ws` - Real-time updates
|
||||
|
||||
### Dashboard Integration
|
||||
|
||||
The system is designed to integrate with your existing React + Vite + Tailwind + Supabase dashboard:
|
||||
|
||||
1. **API Integration**: Use the REST endpoints to display system status
|
||||
2. **WebSocket**: Connect to `/ws` for real-time updates
|
||||
3. **Supabase Storage**: Store recording metadata and system logs
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
/storage/
|
||||
├── camera1/
|
||||
│ ├── camera1_recording_20250726_143022.avi
|
||||
│ └── camera1_recording_20250726_143155.avi
|
||||
├── camera2/
|
||||
│ ├── camera2_recording_20250726_143025.avi
|
||||
│ └── camera2_recording_20250726_143158.avi
|
||||
└── file_index.json
|
||||
```
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
### Log Files
|
||||
|
||||
- `usda_vision_system.log` - Main system log (rotated)
|
||||
- Console output with colored formatting
|
||||
- Component-specific log levels
|
||||
|
||||
### Performance Monitoring
|
||||
|
||||
The system includes built-in performance monitoring:
|
||||
- Startup times
|
||||
- Recording session metrics
|
||||
- MQTT message processing rates
|
||||
- Camera status check intervals
|
||||
|
||||
### Error Tracking
|
||||
|
||||
Comprehensive error tracking with:
|
||||
- Error counts per component
|
||||
- Detailed error context
|
||||
- Automatic recovery attempts
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Camera Not Found**:
|
||||
- Check camera connections
|
||||
- Verify python demo library installation
|
||||
- Run camera discovery: Check logs for enumeration results
|
||||
|
||||
2. **MQTT Connection Failed**:
|
||||
- Verify broker IP and port
|
||||
- Check network connectivity
|
||||
- Verify credentials if authentication is enabled
|
||||
|
||||
3. **Recording Fails**:
|
||||
- Check storage permissions
|
||||
- Verify available disk space
|
||||
- Check camera initialization logs
|
||||
|
||||
4. **API Server Won't Start**:
|
||||
- Check if port 8000 is available
|
||||
- Verify FastAPI dependencies
|
||||
- Check firewall settings
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check system status
|
||||
curl http://localhost:8000/system/status
|
||||
|
||||
# Check camera status
|
||||
curl http://localhost:8000/cameras
|
||||
|
||||
# Manual recording start
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"camera_name": "camera1"}'
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
usda_vision_system/
|
||||
├── core/ # Core functionality
|
||||
├── mqtt/ # MQTT client and handlers
|
||||
├── camera/ # Camera management and recording
|
||||
├── storage/ # File management
|
||||
├── api/ # FastAPI server
|
||||
└── main.py # Application coordinator
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
|
||||
1. **New Camera Type**: Extend `camera/recorder.py`
|
||||
2. **New MQTT Topics**: Update `config.json` and `mqtt/handlers.py`
|
||||
3. **New API Endpoints**: Add to `api/server.py`
|
||||
4. **New Events**: Define in `core/events.py`
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run basic system test
|
||||
python -c "from usda_vision_system import USDAVisionSystem; s = USDAVisionSystem(); print('OK')"
|
||||
|
||||
# Test MQTT connection
|
||||
python -c "from usda_vision_system.mqtt.client import MQTTClient; # ... test code"
|
||||
|
||||
# Test camera discovery
|
||||
python -c "import sys; sys.path.append('python demo'); import mvsdk; print(len(mvsdk.CameraEnumerateDevice()))"
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is developed for USDA research purposes.
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
1. Check the logs in `usda_vision_system.log`
|
||||
2. Review the troubleshooting section
|
||||
3. Check API status at `http://localhost:8000/health`
|
||||
190
web/API Documentations/docs/legacy/TIMEZONE_SETUP_SUMMARY.md
Normal file
190
web/API Documentations/docs/legacy/TIMEZONE_SETUP_SUMMARY.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Time Synchronization Setup - Atlanta, Georgia
|
||||
|
||||
## ✅ Time Synchronization Complete!
|
||||
|
||||
The USDA Vision Camera System has been configured for proper time synchronization with Atlanta, Georgia (Eastern Time Zone).
|
||||
|
||||
## 🕐 What Was Implemented
|
||||
|
||||
### System-Level Time Configuration
|
||||
- **Timezone**: Set to `America/New_York` (Eastern Time)
|
||||
- **Current Status**: Eastern Daylight Time (EDT, UTC-4)
|
||||
- **NTP Sync**: Configured with multiple reliable time servers
|
||||
- **Hardware Clock**: Synchronized with system time
|
||||
|
||||
### Application-Level Timezone Support
|
||||
- **Timezone-Aware Timestamps**: All recordings use Atlanta time
|
||||
- **Automatic DST Handling**: Switches between EST/EDT automatically
|
||||
- **Time Sync Monitoring**: Built-in time synchronization checking
|
||||
- **Consistent Formatting**: Standardized timestamp formats throughout
|
||||
|
||||
## 🔧 Key Features
|
||||
|
||||
### 1. Automatic Time Synchronization
|
||||
```bash
|
||||
# NTP servers configured:
|
||||
- time.nist.gov (NIST atomic clock)
|
||||
- pool.ntp.org (NTP pool)
|
||||
- time.google.com (Google time)
|
||||
- time.cloudflare.com (Cloudflare time)
|
||||
```
|
||||
|
||||
### 2. Timezone-Aware Recording Filenames
|
||||
```
|
||||
Example: camera1_recording_20250725_213241.avi
|
||||
Format: {camera}_{type}_{YYYYMMDD_HHMMSS}.avi
|
||||
Time: Atlanta local time (EDT/EST)
|
||||
```
|
||||
|
||||
### 3. Time Verification Tools
|
||||
- **Startup Check**: Automatic time sync verification on system start
|
||||
- **Manual Check**: `python check_time.py` for on-demand verification
|
||||
- **API Integration**: Time sync status available via REST API
|
||||
|
||||
### 4. Comprehensive Logging
|
||||
```
|
||||
=== TIME SYNCHRONIZATION STATUS ===
|
||||
System time: 2025-07-25 21:32:41 EDT
|
||||
Timezone: EDT (-0400)
|
||||
Daylight Saving: Yes
|
||||
Sync status: synchronized
|
||||
Time difference: 0.10 seconds
|
||||
=====================================
|
||||
```
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
### Automatic Operation
|
||||
The system automatically:
|
||||
- Uses Atlanta time for all timestamps
|
||||
- Handles daylight saving time transitions
|
||||
- Monitors time synchronization status
|
||||
- Logs time-related events
|
||||
|
||||
### Manual Verification
|
||||
```bash
|
||||
# Check time synchronization
|
||||
python check_time.py
|
||||
|
||||
# Test timezone functions
|
||||
python test_timezone.py
|
||||
|
||||
# View system time status
|
||||
timedatectl status
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
```bash
|
||||
# System status includes time info
|
||||
curl http://localhost:8000/system/status
|
||||
|
||||
# Example response includes:
|
||||
{
|
||||
"system_started": true,
|
||||
"uptime_seconds": 3600,
|
||||
"timestamp": "2025-07-25T21:32:41-04:00"
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Current Status
|
||||
|
||||
### Time Synchronization
|
||||
- ✅ **System Timezone**: America/New_York (EDT)
|
||||
- ✅ **NTP Sync**: Active and synchronized
|
||||
- ✅ **Time Accuracy**: Within 0.1 seconds of atomic time
|
||||
- ✅ **DST Support**: Automatic EST/EDT switching
|
||||
|
||||
### Application Integration
|
||||
- ✅ **Recording Timestamps**: Atlanta time zone
|
||||
- ✅ **Log Timestamps**: Timezone-aware logging
|
||||
- ✅ **API Responses**: ISO format with timezone
|
||||
- ✅ **File Naming**: Consistent Atlanta time format
|
||||
|
||||
### Monitoring
|
||||
- ✅ **Startup Verification**: Time sync checked on boot
|
||||
- ✅ **Continuous Monitoring**: Built-in sync status tracking
|
||||
- ✅ **Error Detection**: Alerts for time drift issues
|
||||
- ✅ **Manual Tools**: On-demand verification scripts
|
||||
|
||||
## 🔍 Technical Details
|
||||
|
||||
### Timezone Configuration
|
||||
```json
|
||||
{
|
||||
"system": {
|
||||
"timezone": "America/New_York"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Time Sources
|
||||
1. **Primary**: NIST atomic clock (time.nist.gov)
|
||||
2. **Secondary**: NTP pool servers (pool.ntp.org)
|
||||
3. **Backup**: Google/Cloudflare time servers
|
||||
4. **Fallback**: Local system clock
|
||||
|
||||
### File Naming Convention
|
||||
```
|
||||
Pattern: {camera_name}_recording_{YYYYMMDD_HHMMSS}.avi
|
||||
Example: camera1_recording_20250725_213241.avi
|
||||
Timezone: Always Atlanta local time (EST/EDT)
|
||||
```
|
||||
|
||||
## 🎯 Benefits
|
||||
|
||||
### For Operations
|
||||
- **Consistent Timestamps**: All recordings use Atlanta time
|
||||
- **Easy Correlation**: Timestamps match local business hours
|
||||
- **Automatic DST**: No manual timezone adjustments needed
|
||||
- **Reliable Sync**: Multiple time sources ensure accuracy
|
||||
|
||||
### For Analysis
|
||||
- **Local Time Context**: Recordings timestamped in business timezone
|
||||
- **Accurate Sequencing**: Precise timing for event correlation
|
||||
- **Standard Format**: Consistent naming across all recordings
|
||||
- **Audit Trail**: Complete time synchronization logging
|
||||
|
||||
### For Integration
|
||||
- **Dashboard Ready**: Timezone-aware API responses
|
||||
- **Database Compatible**: ISO format timestamps with timezone
|
||||
- **Log Analysis**: Structured time information in logs
|
||||
- **Monitoring**: Built-in time sync health checks
|
||||
|
||||
## 🔧 Maintenance
|
||||
|
||||
### Regular Checks
|
||||
The system automatically:
|
||||
- Verifies time sync on startup
|
||||
- Logs time synchronization status
|
||||
- Monitors for time drift
|
||||
- Alerts on sync failures
|
||||
|
||||
### Manual Maintenance
|
||||
```bash
|
||||
# Force time sync
|
||||
sudo systemctl restart systemd-timesyncd
|
||||
|
||||
# Check NTP status
|
||||
timedatectl show-timesync --all
|
||||
|
||||
# Verify timezone
|
||||
timedatectl status
|
||||
```
|
||||
|
||||
## 📈 Next Steps
|
||||
|
||||
The time synchronization is now fully operational. The system will:
|
||||
|
||||
1. **Automatically maintain** accurate Atlanta time
|
||||
2. **Generate timestamped recordings** with local time
|
||||
3. **Monitor sync status** and alert on issues
|
||||
4. **Provide timezone-aware** API responses for dashboard integration
|
||||
|
||||
All recording files will now have accurate Atlanta timestamps, making it easy to correlate with local business operations and machine schedules.
|
||||
|
||||
---
|
||||
|
||||
**Time Sync Status**: ✅ **SYNCHRONIZED**
|
||||
**Timezone**: ✅ **America/New_York (EDT)**
|
||||
**Accuracy**: ✅ **±0.1 seconds**
|
||||
**Ready for Production**: ✅ **YES**
|
||||
191
web/API Documentations/docs/legacy/VIDEO_RECORDER_README.md
Normal file
191
web/API Documentations/docs/legacy/VIDEO_RECORDER_README.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Camera Video Recorder
|
||||
|
||||
A Python script for recording videos from GigE cameras using the provided SDK with custom exposure and gain settings.
|
||||
|
||||
## Features
|
||||
|
||||
- **List all available cameras** - Automatically detects and displays all connected cameras
|
||||
- **Custom camera settings** - Set exposure time to 1ms and gain to 3.5x (or custom values)
|
||||
- **Video recording** - Record videos in AVI format with timestamp filenames
|
||||
- **Live preview** - Test camera functionality with live preview mode
|
||||
- **Interactive menu** - User-friendly menu system for all operations
|
||||
- **Automatic cleanup** - Proper resource management and cleanup
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.x
|
||||
- OpenCV (`cv2`)
|
||||
- NumPy
|
||||
- Camera SDK (mvsdk) - included in `python demo` directory
|
||||
- GigE camera connected to the system
|
||||
|
||||
## Installation
|
||||
|
||||
1. Ensure your GigE camera is connected and properly configured
|
||||
2. Make sure the `python demo` directory with `mvsdk.py` is present
|
||||
3. Install required Python packages:
|
||||
```bash
|
||||
pip install opencv-python numpy
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Run the script:
|
||||
```bash
|
||||
python camera_video_recorder.py
|
||||
```
|
||||
|
||||
The script will:
|
||||
1. Display a welcome message and feature overview
|
||||
2. List all available cameras
|
||||
3. Let you select a camera (if multiple are available)
|
||||
4. Allow you to set custom exposure and gain values
|
||||
5. Present an interactive menu with options
|
||||
|
||||
### Menu Options
|
||||
|
||||
1. **Start Recording** - Begin video recording with timestamp filename
|
||||
2. **List Camera Info** - Display detailed camera information
|
||||
3. **Test Camera (Live Preview)** - View live camera feed without recording
|
||||
4. **Exit** - Clean up and exit the program
|
||||
|
||||
### Default Settings
|
||||
|
||||
- **Exposure Time**: 1.0ms (1000 microseconds)
|
||||
- **Gain**: 3.5x
|
||||
- **Video Format**: AVI with XVID codec
|
||||
- **Frame Rate**: 30 FPS
|
||||
- **Output Directory**: `videos/` (created automatically)
|
||||
|
||||
### Recording Controls
|
||||
|
||||
- **Start Recording**: Select option 1 from the menu
|
||||
- **Stop Recording**: Press 'q' in the preview window
|
||||
- **Video Files**: Saved as `videos/camera_recording_YYYYMMDD_HHMMSS.avi`
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
camera_video_recorder.py # Main script
|
||||
python demo/
|
||||
mvsdk.py # Camera SDK wrapper
|
||||
(other demo files)
|
||||
videos/ # Output directory (created automatically)
|
||||
camera_recording_*.avi # Recorded video files
|
||||
```
|
||||
|
||||
## Script Features
|
||||
|
||||
### CameraVideoRecorder Class
|
||||
|
||||
- `list_cameras()` - Enumerate and display available cameras
|
||||
- `initialize_camera()` - Set up camera with custom exposure and gain
|
||||
- `start_recording()` - Initialize video writer and begin recording
|
||||
- `stop_recording()` - Stop recording and save video file
|
||||
- `record_loop()` - Main recording loop with live preview
|
||||
- `cleanup()` - Proper resource cleanup
|
||||
|
||||
### Key Functions
|
||||
|
||||
- **Camera Detection**: Automatically finds all connected GigE cameras
|
||||
- **Settings Validation**: Checks and clamps exposure/gain values to camera limits
|
||||
- **Frame Processing**: Handles both monochrome and color cameras
|
||||
- **Windows Compatibility**: Handles frame flipping for Windows systems
|
||||
- **Error Handling**: Comprehensive error handling and user feedback
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
Camera Video Recorder
|
||||
====================
|
||||
This script allows you to:
|
||||
- List all available cameras
|
||||
- Record videos with custom exposure (1ms) and gain (3.5x) settings
|
||||
- Save videos with timestamps
|
||||
- Stop recording anytime with 'q' key
|
||||
|
||||
Found 1 camera(s):
|
||||
0: GigE Camera Model (GigE) - SN: 12345678
|
||||
|
||||
Using camera: GigE Camera Model
|
||||
|
||||
Camera Settings:
|
||||
Enter exposure time in ms (default 1.0): 1.0
|
||||
Enter gain value (default 3.5): 3.5
|
||||
|
||||
Initializing camera with:
|
||||
- Exposure: 1.0ms
|
||||
- Gain: 3.5x
|
||||
|
||||
Camera type: Color
|
||||
Set exposure time: 1000.0μs
|
||||
Set analog gain: 3.50x (range: 1.00 - 16.00)
|
||||
Camera started successfully
|
||||
|
||||
==================================================
|
||||
Camera Video Recorder Menu
|
||||
==================================================
|
||||
1. Start Recording
|
||||
2. List Camera Info
|
||||
3. Test Camera (Live Preview)
|
||||
4. Exit
|
||||
|
||||
Select option (1-4): 1
|
||||
|
||||
Started recording to: videos/camera_recording_20241223_143022.avi
|
||||
Frame size: (1920, 1080), FPS: 30.0
|
||||
Press 'q' to stop recording...
|
||||
Recording... Press 'q' in the preview window to stop
|
||||
|
||||
Recording stopped!
|
||||
Saved: videos/camera_recording_20241223_143022.avi
|
||||
Frames recorded: 450
|
||||
Duration: 15.2 seconds
|
||||
Average FPS: 29.6
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"No cameras found!"**
|
||||
- Check camera connection
|
||||
- Verify camera power
|
||||
- Ensure network configuration for GigE cameras
|
||||
|
||||
2. **"SDK initialization failed"**
|
||||
- Verify `python demo/mvsdk.py` exists
|
||||
- Check camera drivers are installed
|
||||
|
||||
3. **"Camera initialization failed"**
|
||||
- Camera may be in use by another application
|
||||
- Try disconnecting and reconnecting the camera
|
||||
|
||||
4. **Recording issues**
|
||||
- Ensure sufficient disk space
|
||||
- Check write permissions in the output directory
|
||||
|
||||
### Performance Tips
|
||||
|
||||
- Close other applications using the camera
|
||||
- Ensure adequate system resources (CPU, RAM)
|
||||
- Use SSD storage for better write performance
|
||||
- Adjust frame rate if experiencing dropped frames
|
||||
|
||||
## Customization
|
||||
|
||||
You can modify the script to:
|
||||
- Change video codec (currently XVID)
|
||||
- Adjust target frame rate
|
||||
- Modify output filename format
|
||||
- Add additional camera settings
|
||||
- Change preview window size
|
||||
|
||||
## Notes
|
||||
|
||||
- Videos are saved in the `videos/` directory with timestamp filenames
|
||||
- The script handles both monochrome and color cameras automatically
|
||||
- Frame flipping is handled automatically for Windows systems
|
||||
- All resources are properly cleaned up on exit
|
||||
69
web/README.md
Normal file
69
web/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
137
web/VISION_SYSTEM_README.md
Normal file
137
web/VISION_SYSTEM_README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Vision System Dashboard
|
||||
|
||||
This document describes the Vision System dashboard that has been added to the Pecan Experiments application.
|
||||
|
||||
## Overview
|
||||
|
||||
The Vision System dashboard provides real-time monitoring and control of the USDA Vision Camera System. It displays information about cameras, machines, storage, and recording sessions.
|
||||
|
||||
## Features
|
||||
|
||||
### System Overview
|
||||
- **System Status**: Shows if the vision system is online/offline with uptime information
|
||||
- **MQTT Connection**: Displays MQTT connectivity status and last message timestamp
|
||||
- **Active Recordings**: Shows current number of active recordings and total recordings
|
||||
- **Camera/Machine Count**: Quick overview of connected devices
|
||||
|
||||
### Camera Monitoring
|
||||
- **Real-time Status**: Shows connection status for each camera (camera1, camera2)
|
||||
- **Recording State**: Indicates if cameras are currently recording
|
||||
- **Device Information**: Displays friendly names and serial numbers
|
||||
- **Error Reporting**: Shows any camera errors or issues
|
||||
- **Current Recording Files**: Shows active recording filenames
|
||||
|
||||
### Machine Status
|
||||
- **Machine States**: Monitors vibratory conveyor, blower separator, and other machines
|
||||
- **MQTT Topics**: Shows the MQTT topics for each machine
|
||||
- **Last Updated**: Timestamps for when each machine status was last updated
|
||||
- **State Colors**: Visual indicators for machine states (on/off/running/stopped)
|
||||
|
||||
### Storage Management
|
||||
- **Disk Usage**: Visual progress bar showing total disk usage
|
||||
- **File Statistics**: Total files, total size, and free space
|
||||
- **Per-Camera Breakdown**: Storage usage statistics for each camera
|
||||
- **Storage Path**: Shows the base storage directory
|
||||
|
||||
### Recording Sessions
|
||||
- **Recent Recordings Table**: Shows the latest recording sessions
|
||||
- **Recording Details**: Filename, camera, status, duration, file size, start time
|
||||
- **Status Indicators**: Visual status badges for completed/active/failed recordings
|
||||
|
||||
## API Integration
|
||||
|
||||
The dashboard connects to the Vision System API running on `http://vision:8000` and provides:
|
||||
|
||||
### Endpoints Used
|
||||
- `GET /system/status` - System overview and status
|
||||
- `GET /cameras` - Camera status and information
|
||||
- `GET /machines` - Machine status and MQTT data
|
||||
- `GET /storage/stats` - Storage usage statistics
|
||||
- `GET /recordings` - Recording session information
|
||||
|
||||
### Auto-Refresh
|
||||
- The dashboard automatically refreshes data every 5 seconds
|
||||
- Manual refresh button available for immediate updates
|
||||
- Loading indicators show when data is being fetched
|
||||
|
||||
## Access Control
|
||||
|
||||
The Vision System dashboard is accessible to all authenticated users regardless of role:
|
||||
- **Admin**: Full access to all features
|
||||
- **Conductor**: Full access to all features
|
||||
- **Analyst**: Full access to all features
|
||||
- **Data Recorder**: Full access to all features
|
||||
|
||||
## Error Handling
|
||||
|
||||
The dashboard includes comprehensive error handling:
|
||||
- **Connection Errors**: Shows user-friendly messages when the vision system is unavailable
|
||||
- **API Errors**: Displays specific error messages from the vision system API
|
||||
- **Retry Functionality**: "Try Again" button to retry failed requests
|
||||
- **Graceful Degradation**: Shows partial data if some API calls fail
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Files Added/Modified
|
||||
- `src/lib/visionApi.ts` - API client for vision system integration
|
||||
- `src/components/VisionSystem.tsx` - Main dashboard component
|
||||
- `src/components/DashboardLayout.tsx` - Added routing for vision system
|
||||
- `src/components/Sidebar.tsx` - Added menu item for vision system
|
||||
- `src/components/TopNavbar.tsx` - Added page title for vision system
|
||||
|
||||
### Dependencies
|
||||
- Uses existing React/TypeScript setup
|
||||
- Leverages Tailwind CSS for styling
|
||||
- No additional dependencies required
|
||||
|
||||
### API Client Features
|
||||
- TypeScript interfaces for all API responses
|
||||
- Comprehensive error handling
|
||||
- Utility functions for formatting (bytes, duration, uptime)
|
||||
- Singleton pattern for API client instance
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Navigate to Vision System**: Click "Vision System" in the sidebar menu
|
||||
2. **Monitor Status**: View real-time system, camera, and machine status
|
||||
3. **Check Storage**: Monitor disk usage and file statistics
|
||||
4. **Review Recordings**: See recent recording sessions and their details
|
||||
5. **Refresh Data**: Use the refresh button or wait for auto-refresh
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Failed to fetch vision system data"**
|
||||
- Ensure the vision system API is running on vision:8000
|
||||
- Check network connectivity
|
||||
- Verify the vision system service is started
|
||||
|
||||
2. **Empty Dashboard**
|
||||
- Vision system may not have any cameras or machines configured
|
||||
- Check vision system configuration
|
||||
- Verify MQTT connectivity
|
||||
|
||||
3. **Outdated Information**
|
||||
- Data refreshes every 5 seconds automatically
|
||||
- Use the manual refresh button for immediate updates
|
||||
- Check if vision system is responding to API calls
|
||||
|
||||
### API Base URL Configuration
|
||||
|
||||
The API base URL is configured in `src/lib/visionApi.ts`:
|
||||
```typescript
|
||||
const VISION_API_BASE_URL = 'http://vision:8000'
|
||||
```
|
||||
|
||||
To change the API endpoint, modify this constant and rebuild the application.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for the vision system dashboard:
|
||||
- **Camera Controls**: Add start/stop recording buttons
|
||||
- **Camera Recovery**: Add diagnostic and recovery action buttons
|
||||
- **File Management**: Add file browsing and cleanup functionality
|
||||
- **Real-time Streaming**: Add live camera feed display
|
||||
- **Alerts**: Add notifications for system issues
|
||||
- **Historical Data**: Add charts and trends for system metrics
|
||||
435
web/api-endpoints.http
Normal file
435
web/api-endpoints.http
Normal file
@@ -0,0 +1,435 @@
|
||||
###############################################################################
|
||||
# USDA Vision Camera System - Complete API Endpoints Documentation
|
||||
# Base URL: http://vision:8000
|
||||
###############################################################################
|
||||
|
||||
###############################################################################
|
||||
# SYSTEM ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Root endpoint - API information
|
||||
GET http://vision:8000/
|
||||
# Response: SuccessResponse
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "USDA Vision Camera System API",
|
||||
# "data": null,
|
||||
# "timestamp": "2025-07-28T12:00:00"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Health check
|
||||
GET http://vision:8000/health
|
||||
# Response: Simple health status
|
||||
# {
|
||||
# "status": "healthy",
|
||||
# "timestamp": "2025-07-28T12:00:00"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Get system status
|
||||
GET http://vision:8000/system/status
|
||||
# Response: SystemStatusResponse
|
||||
# {
|
||||
# "system_started": true,
|
||||
# "mqtt_connected": true,
|
||||
# "last_mqtt_message": "2025-07-28T12:00:00",
|
||||
# "machines": {
|
||||
# "vibratory_conveyor": {
|
||||
# "name": "vibratory_conveyor",
|
||||
# "state": "off",
|
||||
# "last_updated": "2025-07-28T12:00:00"
|
||||
# }
|
||||
# },
|
||||
# "cameras": {
|
||||
# "camera1": {
|
||||
# "name": "camera1",
|
||||
# "status": "connected",
|
||||
# "is_recording": false
|
||||
# }
|
||||
# },
|
||||
# "active_recordings": 0,
|
||||
# "total_recordings": 5,
|
||||
# "uptime_seconds": 3600.5
|
||||
# }
|
||||
|
||||
###############################################################################
|
||||
# MACHINE ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Get all machines status
|
||||
GET http://vision:8000/machines
|
||||
# Response: Dict[str, MachineStatusResponse]
|
||||
# {
|
||||
# "vibratory_conveyor": {
|
||||
# "name": "vibratory_conveyor",
|
||||
# "state": "off",
|
||||
# "last_updated": "2025-07-28T12:00:00",
|
||||
# "last_message": "off",
|
||||
# "mqtt_topic": "vision/vibratory_conveyor/state"
|
||||
# },
|
||||
# "blower_separator": {
|
||||
# "name": "blower_separator",
|
||||
# "state": "on",
|
||||
# "last_updated": "2025-07-28T12:00:00",
|
||||
# "last_message": "on",
|
||||
# "mqtt_topic": "vision/blower_separator/state"
|
||||
# }
|
||||
# }
|
||||
|
||||
###############################################################################
|
||||
# MQTT ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Get MQTT status and statistics
|
||||
GET http://vision:8000/mqtt/status
|
||||
# Response: MQTTStatusResponse
|
||||
# {
|
||||
# "connected": true,
|
||||
# "broker_host": "192.168.1.110",
|
||||
# "broker_port": 1883,
|
||||
# "subscribed_topics": [
|
||||
# "vision/vibratory_conveyor/state",
|
||||
# "vision/blower_separator/state"
|
||||
# ],
|
||||
# "last_message_time": "2025-07-28T12:00:00",
|
||||
# "message_count": 42,
|
||||
# "error_count": 0,
|
||||
# "uptime_seconds": 3600.5
|
||||
# }
|
||||
|
||||
### Get recent MQTT events history
|
||||
GET http://vision:8000/mqtt/events
|
||||
# Optional query parameter: limit (default: 5, max: 50)
|
||||
# Response: MQTTEventsHistoryResponse
|
||||
# {
|
||||
# "events": [
|
||||
# {
|
||||
# "machine_name": "vibratory_conveyor",
|
||||
# "topic": "vision/vibratory_conveyor/state",
|
||||
# "payload": "on",
|
||||
# "normalized_state": "on",
|
||||
# "timestamp": "2025-07-28T15:30:45.123456",
|
||||
# "message_number": 15
|
||||
# },
|
||||
# {
|
||||
# "machine_name": "blower_separator",
|
||||
# "topic": "vision/blower_separator/state",
|
||||
# "payload": "off",
|
||||
# "normalized_state": "off",
|
||||
# "timestamp": "2025-07-28T15:29:12.654321",
|
||||
# "message_number": 14
|
||||
# }
|
||||
# ],
|
||||
# "total_events": 15,
|
||||
# "last_updated": "2025-07-28T15:30:45.123456"
|
||||
# }
|
||||
|
||||
### Get recent MQTT events with custom limit
|
||||
GET http://vision:8000/mqtt/events?limit=10
|
||||
|
||||
###############################################################################
|
||||
# CAMERA ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Get all cameras status
|
||||
GET http://vision:8000/cameras
|
||||
# Response: Dict[str, CameraStatusResponse]
|
||||
# {
|
||||
# "camera1": {
|
||||
# "name": "camera1",
|
||||
# "status": "connected",
|
||||
# "is_recording": false,
|
||||
# "last_checked": "2025-07-28T12:00:00",
|
||||
# "last_error": null,
|
||||
# "device_info": {
|
||||
# "friendly_name": "MindVision Camera",
|
||||
# "serial_number": "ABC123"
|
||||
# },
|
||||
# "current_recording_file": null,
|
||||
# "recording_start_time": null,
|
||||
# "auto_record_on_machine_start": false
|
||||
# }
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Get specific camera status
|
||||
GET http://vision:8000/cameras/camera1/status
|
||||
### Get specific camera status
|
||||
GET http://vision:8000/cameras/camera2/status
|
||||
# Response: CameraStatusResponse (same as above for single camera)
|
||||
|
||||
###############################################################################
|
||||
# RECORDING CONTROL ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Start recording (with all optional parameters)
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"filename": "test_recording.mp4",
|
||||
"exposure_ms": 1.5,
|
||||
"gain": 3.0,
|
||||
"fps": 0
|
||||
}
|
||||
###
|
||||
# Request Parameters (all optional):
|
||||
# - filename: string - Custom filename (datetime prefix auto-added)
|
||||
# - exposure_ms: float - Exposure time in milliseconds
|
||||
# - gain: float - Camera gain value
|
||||
# - fps: float - Target frames per second (0 = maximum speed, omit = use config default)
|
||||
#
|
||||
# Response: StartRecordingResponse
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "Recording started for camera1",
|
||||
# "filename": "20250728_120000_test_recording.mp4"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Start recording (minimal - only filename)
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"filename": "simple_test.mp4"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Start recording (only camera settings)
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"exposure_ms": 2.0,
|
||||
"gain": 4.0,
|
||||
"fps": 0
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Start recording (empty body - all defaults)
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{}
|
||||
|
||||
###
|
||||
|
||||
### Stop recording
|
||||
POST http://vision:8000/cameras/camera1/stop-recording
|
||||
### Stop recording
|
||||
POST http://vision:8000/cameras/camera2/stop-recording
|
||||
# No request body required
|
||||
# Response: StopRecordingResponse
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "Recording stopped for camera1",
|
||||
# "duration_seconds": 45.2
|
||||
# }
|
||||
|
||||
###############################################################################
|
||||
# CAMERA RECOVERY & DIAGNOSTICS ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Test camera connection
|
||||
POST http://vision:8000/cameras/camera1/test-connection
|
||||
POST http://vision:8000/cameras/camera2/test-connection
|
||||
# No request body required
|
||||
# Response: CameraTestResponse
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "Camera camera1 connection test passed",
|
||||
# "camera_name": "camera1",
|
||||
# "timestamp": "2025-07-28T12:00:00"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Reconnect camera (soft recovery)
|
||||
POST http://vision:8000/cameras/camera1/reconnect
|
||||
POST http://vision:8000/cameras/camera2/reconnect
|
||||
# No request body required
|
||||
# Response: CameraRecoveryResponse
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "Camera camera1 reconnected successfully",
|
||||
# "camera_name": "camera1",
|
||||
# "operation": "reconnect",
|
||||
# "timestamp": "2025-07-28T12:00:00"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Restart camera grab process
|
||||
POST http://vision:8000/cameras/camera1/restart-grab
|
||||
POST http://vision:8000/cameras/camera2/restart-grab
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###
|
||||
|
||||
### Reset camera timestamp
|
||||
POST http://vision:8000/cameras/camera1/reset-timestamp
|
||||
POST http://vision:8000/cameras/camera2/reset-timestamp
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###
|
||||
|
||||
### Full camera reset (hard recovery)
|
||||
POST http://vision:8000/cameras/camera1/full-reset
|
||||
### Full camera reset (hard recovery)
|
||||
|
||||
POST http://vision:8000/cameras/camera2/full-reset
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###
|
||||
|
||||
### Reinitialize failed camera
|
||||
POST http://vision:8000/cameras/camera1/reinitialize
|
||||
### Reinitialize failed camera
|
||||
|
||||
POST http://vision:8000/cameras/camera2/reinitialize
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###############################################################################
|
||||
# RECORDING SESSIONS ENDPOINT
|
||||
###############################################################################
|
||||
|
||||
### Get all recording sessions
|
||||
GET http://vision:8000/recordings
|
||||
# Response: Dict[str, RecordingInfoResponse]
|
||||
# {
|
||||
# "rec_001": {
|
||||
# "camera_name": "camera1",
|
||||
# "filename": "20250728_120000_test.avi",
|
||||
# "start_time": "2025-07-28T12:00:00",
|
||||
# "state": "completed",
|
||||
# "end_time": "2025-07-28T12:05:00",
|
||||
# "file_size_bytes": 1048576,
|
||||
# "frame_count": 1500,
|
||||
# "duration_seconds": 300.0,
|
||||
# "error_message": null
|
||||
# }
|
||||
# }
|
||||
|
||||
###############################################################################
|
||||
# STORAGE ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Get storage statistics
|
||||
GET http://vision:8000/storage/stats
|
||||
# Response: StorageStatsResponse
|
||||
# {
|
||||
# "base_path": "/storage",
|
||||
# "total_files": 25,
|
||||
# "total_size_bytes": 52428800,
|
||||
# "cameras": {
|
||||
# "camera1": {
|
||||
# "file_count": 15,
|
||||
# "total_size_bytes": 31457280
|
||||
# }
|
||||
# },
|
||||
# "disk_usage": {
|
||||
# "total": 1000000000,
|
||||
# "used": 500000000,
|
||||
# "free": 500000000
|
||||
# }
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Get recording files list (with filters)
|
||||
POST http://vision:8000/storage/files
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"camera_name": "camera1",
|
||||
"start_date": "2025-07-25T00:00:00",
|
||||
"end_date": "2025-07-28T23:59:59",
|
||||
"limit": 50
|
||||
}
|
||||
# Request Parameters (all optional):
|
||||
# - camera_name: string - Filter by specific camera
|
||||
# - start_date: string (ISO format) - Filter files from this date
|
||||
# - end_date: string (ISO format) - Filter files until this date
|
||||
# - limit: integer (max 1000, default 100) - Maximum number of files to return
|
||||
#
|
||||
# Response: FileListResponse
|
||||
# {
|
||||
# "files": [
|
||||
# {
|
||||
# "filename": "20250728_120000_test.avi",
|
||||
# "camera_name": "camera1",
|
||||
# "file_size_bytes": 1048576,
|
||||
# "created_date": "2025-07-28T12:00:00",
|
||||
# "duration_seconds": 300.0
|
||||
# }
|
||||
# ],
|
||||
# "total_count": 1
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Get all files (no camera filter)
|
||||
POST http://vision:8000/storage/files
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"limit": 100
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Cleanup old storage files
|
||||
POST http://vision:8000/storage/cleanup
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"max_age_days": 7
|
||||
}
|
||||
# Request Parameters:
|
||||
# - max_age_days: integer (optional) - Remove files older than this many days
|
||||
# If not provided, uses config default (30 days)
|
||||
#
|
||||
# Response: CleanupResponse
|
||||
# {
|
||||
# "files_removed": 5,
|
||||
# "bytes_freed": 10485760,
|
||||
# "errors": []
|
||||
# }
|
||||
|
||||
###############################################################################
|
||||
# ERROR RESPONSES
|
||||
###############################################################################
|
||||
# All endpoints may return ErrorResponse on failure:
|
||||
# {
|
||||
# "error": "Error description",
|
||||
# "details": "Additional error details",
|
||||
# "timestamp": "2025-07-28T12:00:00"
|
||||
# }
|
||||
# Common HTTP status codes:
|
||||
# - 200: Success
|
||||
# - 400: Bad Request (invalid parameters)
|
||||
# - 404: Not Found (camera/resource not found)
|
||||
# - 500: Internal Server Error
|
||||
# - 503: Service Unavailable (camera manager not available)
|
||||
|
||||
###############################################################################
|
||||
# NOTES
|
||||
###############################################################################
|
||||
# 1. All timestamps are in ISO 8601 format
|
||||
# 2. File sizes are in bytes
|
||||
# 3. Camera names: "camera1", "camera2"
|
||||
# 4. Machine names: "vibratory_conveyor", "blower_separator"
|
||||
# 5. FPS behavior:
|
||||
# - fps > 0: Capture at specified frame rate
|
||||
# - fps = 0: Capture at MAXIMUM possible speed (no delay)
|
||||
# - fps omitted: Uses camera config default
|
||||
# 6. Filenames automatically get datetime prefix: YYYYMMDD_HHMMSS_filename.avi
|
||||
# 7. Recovery endpoints should be used in order: test-connection → reconnect → restart-grab → full-reset → reinitialize
|
||||
162
web/docs/AUTO_RECORDING_SETUP.md
Normal file
162
web/docs/AUTO_RECORDING_SETUP.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 🤖 Auto-Recording Setup Guide
|
||||
|
||||
This guide explains how to set up and test the automatic recording functionality that triggers camera recording when machines turn on/off via MQTT.
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
The auto-recording feature allows cameras to automatically start recording when their associated machine turns on and stop recording when the machine turns off. This is based on MQTT messages received from the machines.
|
||||
|
||||
## 🔧 Setup Steps
|
||||
|
||||
### 1. Configure Camera Auto-Recording
|
||||
|
||||
1. **Access Vision System**: Navigate to the Vision System page in the dashboard
|
||||
2. **Open Camera Configuration**: Click "Configure Camera" on any camera (admin access required)
|
||||
3. **Enable Auto-Recording**: In the "Auto-Recording" section, check the box "Automatically start recording when machine turns on"
|
||||
4. **Save Configuration**: Click "Save Changes" to apply the setting
|
||||
|
||||
### 2. Machine-Camera Mapping
|
||||
|
||||
The system uses the `machine_topic` field in camera configuration to determine which MQTT topic to monitor:
|
||||
|
||||
- **Camera 1** (`camera1`) → monitors `blower_separator`
|
||||
- **Camera 2** (`camera2`) → monitors `vibratory_conveyor`
|
||||
|
||||
### 3. Start Auto-Recording Manager
|
||||
|
||||
1. **Navigate to Vision System**: Go to the Vision System page
|
||||
2. **Find Auto-Recording Section**: Look for the "Auto-Recording" panel (admin only)
|
||||
3. **Start Monitoring**: Click the "Start" button to begin monitoring MQTT events
|
||||
4. **Monitor Status**: The panel will show the current state of all cameras and their auto-recording status
|
||||
|
||||
## 🧪 Testing the Functionality
|
||||
|
||||
### Test Scenario 1: Manual MQTT Message Simulation
|
||||
|
||||
If you have access to the MQTT broker, you can test by sending messages:
|
||||
|
||||
```bash
|
||||
# Turn on the vibratory conveyor (should start recording on camera2)
|
||||
mosquitto_pub -h 192.168.1.110 -t "vision/vibratory_conveyor/state" -m "on"
|
||||
|
||||
# Turn off the vibratory conveyor (should stop recording on camera2)
|
||||
mosquitto_pub -h 192.168.1.110 -t "vision/vibratory_conveyor/state" -m "off"
|
||||
|
||||
# Turn on the blower separator (should start recording on camera1)
|
||||
mosquitto_pub -h 192.168.1.110 -t "vision/blower_separator/state" -m "on"
|
||||
|
||||
# Turn off the blower separator (should stop recording on camera1)
|
||||
mosquitto_pub -h 192.168.1.110 -t "vision/blower_separator/state" -m "off"
|
||||
```
|
||||
|
||||
### Test Scenario 2: Physical Machine Operation
|
||||
|
||||
1. **Enable Auto-Recording**: Ensure auto-recording is enabled for the desired cameras
|
||||
2. **Start Auto-Recording Manager**: Make sure the auto-recording manager is running
|
||||
3. **Operate Machine**: Turn on the physical machine (conveyor or blower)
|
||||
4. **Verify Recording**: Check that the camera starts recording automatically
|
||||
5. **Stop Machine**: Turn off the machine
|
||||
6. **Verify Stop**: Check that recording stops automatically
|
||||
|
||||
## 📊 Monitoring and Verification
|
||||
|
||||
### Auto-Recording Status Panel
|
||||
|
||||
The Vision System page includes an "Auto-Recording" status panel that shows:
|
||||
|
||||
- **Manager Status**: Whether the auto-recording manager is active
|
||||
- **Camera States**: For each camera:
|
||||
- Machine state (ON/OFF)
|
||||
- Recording status (YES/NO)
|
||||
- Auto-record enabled status
|
||||
- Last state change timestamp
|
||||
|
||||
### MQTT Events Panel
|
||||
|
||||
Monitor the MQTT Events section to see:
|
||||
|
||||
- Recent machine state changes
|
||||
- MQTT message timestamps
|
||||
- Message payloads
|
||||
|
||||
### Recording Files
|
||||
|
||||
Check the storage section for automatically created recording files:
|
||||
|
||||
- Files will be named with pattern: `auto_{machine_name}_{timestamp}.avi`
|
||||
- Example: `auto_vibratory_conveyor_2025-07-29T10-30-45-123Z.avi`
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Auto-Recording Not Starting
|
||||
|
||||
1. **Check Configuration**: Verify auto-recording is enabled in camera config
|
||||
2. **Check Manager Status**: Ensure auto-recording manager is running
|
||||
3. **Check MQTT Connection**: Verify MQTT client is connected
|
||||
4. **Check Machine Topic**: Ensure camera's machine_topic matches MQTT topic
|
||||
5. **Check Permissions**: Ensure you have admin access
|
||||
|
||||
### Recording Not Stopping
|
||||
|
||||
1. **Check MQTT Messages**: Verify "off" messages are being received
|
||||
2. **Check Manager Logs**: Look for error messages in browser console
|
||||
3. **Manual Stop**: Use manual stop recording if needed
|
||||
|
||||
### Performance Issues
|
||||
|
||||
1. **Polling Interval**: The manager polls MQTT events every 2 seconds by default
|
||||
2. **Event Processing**: Only new events since last poll are processed
|
||||
3. **Error Handling**: Failed operations are logged but don't stop the manager
|
||||
|
||||
## 🔧 Configuration Options
|
||||
|
||||
### Camera Configuration Fields
|
||||
|
||||
```json
|
||||
{
|
||||
"auto_record_on_machine_start": true, // Enable/disable auto-recording
|
||||
"machine_topic": "vibratory_conveyor", // MQTT topic to monitor
|
||||
// ... other camera settings
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Recording Manager Settings
|
||||
|
||||
- **Polling Interval**: 2000ms (configurable in code)
|
||||
- **Event Batch Size**: 50 events per poll
|
||||
- **Filename Pattern**: `auto_{machine_name}_{timestamp}.avi`
|
||||
|
||||
## 📝 API Endpoints
|
||||
|
||||
### Camera Configuration
|
||||
|
||||
- `GET /cameras/{camera_name}/config` - Get camera configuration
|
||||
- `PUT /cameras/{camera_name}/config` - Update camera configuration
|
||||
|
||||
### Recording Control
|
||||
|
||||
- `POST /cameras/{camera_name}/start-recording` - Start recording
|
||||
- `POST /cameras/{camera_name}/stop-recording` - Stop recording
|
||||
|
||||
### MQTT Monitoring
|
||||
|
||||
- `GET /mqtt/events?limit=50` - Get recent MQTT events
|
||||
- `GET /machines` - Get machine states
|
||||
|
||||
## 🚨 Important Notes
|
||||
|
||||
1. **Admin Access Required**: Auto-recording configuration requires admin privileges
|
||||
2. **Backend Integration**: This frontend implementation requires corresponding backend support
|
||||
3. **MQTT Dependency**: Functionality depends on stable MQTT connection
|
||||
4. **Storage Space**: Monitor storage usage as auto-recording can generate many files
|
||||
5. **Network Reliability**: Ensure stable network connection for MQTT messages
|
||||
|
||||
## 🔄 Future Enhancements
|
||||
|
||||
Potential improvements for the auto-recording system:
|
||||
|
||||
1. **Recording Schedules**: Time-based recording rules
|
||||
2. **Storage Management**: Automatic cleanup of old recordings
|
||||
3. **Alert System**: Notifications for recording failures
|
||||
4. **Advanced Triggers**: Multiple machine dependencies
|
||||
5. **Recording Profiles**: Different settings per machine state
|
||||
300
web/docs/MODULAR_ARCHITECTURE_GUIDE.md
Normal file
300
web/docs/MODULAR_ARCHITECTURE_GUIDE.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# 🏗️ Modular Architecture Guide
|
||||
|
||||
This guide demonstrates the modular architecture patterns implemented in the video streaming feature and how to apply them to other parts of the project.
|
||||
|
||||
## 🎯 Goals
|
||||
|
||||
- **Separation of Concerns**: Each module has a single responsibility
|
||||
- **Reusability**: Components can be used across different parts of the application
|
||||
- **Maintainability**: Easy to understand, modify, and test individual pieces
|
||||
- **Scalability**: Easy to add new features without affecting existing code
|
||||
|
||||
## 📁 Feature-Based Structure
|
||||
|
||||
```
|
||||
src/features/video-streaming/
|
||||
├── components/ # UI Components
|
||||
│ ├── VideoPlayer.tsx
|
||||
│ ├── VideoCard.tsx
|
||||
│ ├── VideoList.tsx
|
||||
│ ├── VideoModal.tsx
|
||||
│ ├── VideoThumbnail.tsx
|
||||
│ └── index.ts
|
||||
├── hooks/ # Custom React Hooks
|
||||
│ ├── useVideoList.ts
|
||||
│ ├── useVideoPlayer.ts
|
||||
│ ├── useVideoInfo.ts
|
||||
│ └── index.ts
|
||||
├── services/ # API & Business Logic
|
||||
│ └── videoApi.ts
|
||||
├── types/ # TypeScript Definitions
|
||||
│ └── index.ts
|
||||
├── utils/ # Pure Utility Functions
|
||||
│ └── videoUtils.ts
|
||||
├── VideoStreamingPage.tsx # Main Feature Page
|
||||
└── index.ts # Feature Export
|
||||
```
|
||||
|
||||
## 🧩 Layer Responsibilities
|
||||
|
||||
### 1. **Components Layer** (`/components`)
|
||||
- **Purpose**: Pure UI components that handle rendering and user interactions
|
||||
- **Rules**:
|
||||
- No direct API calls
|
||||
- Receive data via props
|
||||
- Emit events via callbacks
|
||||
- Minimal business logic
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// ✅ Good: Pure component with clear props
|
||||
export const VideoCard: React.FC<VideoCardProps> = ({
|
||||
video,
|
||||
onClick,
|
||||
showMetadata = true,
|
||||
}) => {
|
||||
return (
|
||||
<div onClick={() => onClick?.(video)}>
|
||||
{/* UI rendering */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ❌ Bad: Component with API calls
|
||||
export const VideoCard = () => {
|
||||
const [video, setVideo] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/videos/123').then(/* ... */); // Don't do this!
|
||||
}, []);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. **Hooks Layer** (`/hooks`)
|
||||
- **Purpose**: Manage state, side effects, and provide data to components
|
||||
- **Rules**:
|
||||
- Handle API calls and data fetching
|
||||
- Manage component state
|
||||
- Provide clean interfaces to components
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// ✅ Good: Hook handles complexity, provides simple interface
|
||||
export function useVideoList(options = {}) {
|
||||
const [videos, setVideos] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchVideos = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await videoApiService.getVideos();
|
||||
setVideos(data.videos);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { videos, loading, refetch: fetchVideos };
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **Services Layer** (`/services`)
|
||||
- **Purpose**: Handle external dependencies (APIs, storage, etc.)
|
||||
- **Rules**:
|
||||
- Pure functions or classes
|
||||
- No React dependencies
|
||||
- Handle errors gracefully
|
||||
- Provide consistent interfaces
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// ✅ Good: Service handles API complexity
|
||||
export class VideoApiService {
|
||||
async getVideos(params = {}) {
|
||||
try {
|
||||
const response = await fetch(this.buildUrl('/videos', params));
|
||||
return await this.handleResponse(response);
|
||||
} catch (error) {
|
||||
throw new VideoApiError('FETCH_ERROR', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **Types Layer** (`/types`)
|
||||
- **Purpose**: Centralized TypeScript definitions
|
||||
- **Rules**:
|
||||
- Define all interfaces and types
|
||||
- Export from index.ts
|
||||
- Keep types close to their usage
|
||||
|
||||
### 5. **Utils Layer** (`/utils`)
|
||||
- **Purpose**: Pure utility functions
|
||||
- **Rules**:
|
||||
- No side effects
|
||||
- Easily testable
|
||||
- Single responsibility
|
||||
|
||||
## 🔄 Component Composition Patterns
|
||||
|
||||
### Small, Focused Components
|
||||
|
||||
Instead of large monolithic components, create small, focused ones:
|
||||
|
||||
```tsx
|
||||
// ✅ Good: Small, focused components
|
||||
<VideoList>
|
||||
{videos.map(video => (
|
||||
<VideoCard key={video.id} video={video} onClick={onVideoSelect} />
|
||||
))}
|
||||
</VideoList>
|
||||
|
||||
// ❌ Bad: Monolithic component
|
||||
<VideoSystemPage>
|
||||
{/* 500+ lines of mixed concerns */}
|
||||
</VideoSystemPage>
|
||||
```
|
||||
|
||||
### Composition over Inheritance
|
||||
|
||||
```tsx
|
||||
// ✅ Good: Compose features
|
||||
export const VideoStreamingPage = () => {
|
||||
const { videos, loading } = useVideoList();
|
||||
const [selectedVideo, setSelectedVideo] = useState(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<VideoList videos={videos} onVideoSelect={setSelectedVideo} />
|
||||
<VideoModal video={selectedVideo} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 🎨 Applying to Existing Components
|
||||
|
||||
### Example: Breaking Down VisionSystem Component
|
||||
|
||||
**Current Structure (Monolithic):**
|
||||
```tsx
|
||||
// ❌ Current: One large component
|
||||
export const VisionSystem = () => {
|
||||
// 900+ lines of mixed concerns
|
||||
return (
|
||||
<div>
|
||||
{/* System status */}
|
||||
{/* Camera cards */}
|
||||
{/* Storage info */}
|
||||
{/* MQTT status */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Proposed Modular Structure:**
|
||||
```
|
||||
src/features/vision-system/
|
||||
├── components/
|
||||
│ ├── SystemStatusCard.tsx
|
||||
│ ├── CameraCard.tsx
|
||||
│ ├── CameraGrid.tsx
|
||||
│ ├── StorageOverview.tsx
|
||||
│ ├── MqttStatus.tsx
|
||||
│ └── index.ts
|
||||
├── hooks/
|
||||
│ ├── useSystemStatus.ts
|
||||
│ ├── useCameraList.ts
|
||||
│ └── index.ts
|
||||
├── services/
|
||||
│ └── visionApi.ts
|
||||
└── VisionSystemPage.tsx
|
||||
```
|
||||
|
||||
**Refactored Usage:**
|
||||
```tsx
|
||||
// ✅ Better: Composed from smaller parts
|
||||
export const VisionSystemPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<SystemStatusCard />
|
||||
<CameraGrid />
|
||||
<StorageOverview />
|
||||
<MqttStatus />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Now you can reuse components elsewhere:
|
||||
export const DashboardHome = () => {
|
||||
return (
|
||||
<div>
|
||||
<SystemStatusCard /> {/* Reused! */}
|
||||
<QuickStats />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 📋 Migration Strategy
|
||||
|
||||
### Phase 1: Extract Utilities
|
||||
1. Move pure functions to `/utils`
|
||||
2. Move types to `/types`
|
||||
3. Create service classes for API calls
|
||||
|
||||
### Phase 2: Extract Hooks
|
||||
1. Create custom hooks for data fetching
|
||||
2. Move state management to hooks
|
||||
3. Simplify component logic
|
||||
|
||||
### Phase 3: Break Down Components
|
||||
1. Identify distinct UI sections
|
||||
2. Extract to separate components
|
||||
3. Use composition in parent components
|
||||
|
||||
### Phase 4: Feature Organization
|
||||
1. Group related components, hooks, and services
|
||||
2. Create feature-level exports
|
||||
3. Update imports across the application
|
||||
|
||||
## 🧪 Testing Benefits
|
||||
|
||||
Modular architecture makes testing much easier:
|
||||
|
||||
```tsx
|
||||
// ✅ Easy to test individual pieces
|
||||
describe('VideoCard', () => {
|
||||
it('displays video information', () => {
|
||||
render(<VideoCard video={mockVideo} />);
|
||||
expect(screen.getByText(mockVideo.filename)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoList', () => {
|
||||
it('fetches videos on mount', async () => {
|
||||
const { result } = renderHook(() => useVideoList());
|
||||
await waitFor(() => {
|
||||
expect(result.current.videos).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 🚀 Benefits Achieved
|
||||
|
||||
1. **Reusability**: `VideoCard` can be used in lists, grids, or modals
|
||||
2. **Maintainability**: Each file has a single, clear purpose
|
||||
3. **Testability**: Small, focused units are easy to test
|
||||
4. **Developer Experience**: Clear structure makes onboarding easier
|
||||
5. **Performance**: Smaller components enable better optimization
|
||||
|
||||
## 📝 Best Practices
|
||||
|
||||
1. **Start Small**: Begin with one feature and apply patterns gradually
|
||||
2. **Single Responsibility**: Each file should have one clear purpose
|
||||
3. **Clear Interfaces**: Use TypeScript to define clear contracts
|
||||
4. **Consistent Naming**: Follow naming conventions across features
|
||||
5. **Documentation**: Document complex logic and interfaces
|
||||
|
||||
This modular approach transforms large, hard-to-maintain components into small, reusable, and testable pieces that can be composed together to create powerful features.
|
||||
145
web/docs/MP4_FRONTEND_IMPLEMENTATION_STATUS.md
Normal file
145
web/docs/MP4_FRONTEND_IMPLEMENTATION_STATUS.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 🎥 MP4 Frontend Implementation Status
|
||||
|
||||
## ✅ Implementation Complete & API-Aligned
|
||||
|
||||
The frontend has been successfully updated to match the actual camera configuration API structure with full MP4 format support and proper field categorization.
|
||||
|
||||
## 🔧 Changes Made
|
||||
|
||||
### 1. **TypeScript Types Updated** (`src/lib/visionApi.ts`)
|
||||
|
||||
- **Complete API alignment** with actual camera configuration structure
|
||||
- **Required video format fields**: `video_format`, `video_codec`, `video_quality`
|
||||
- **Added missing fields**: `wb_red_gain`, `wb_green_gain`, `wb_blue_gain`
|
||||
- **Proper field categorization**: Read-only vs real-time configurable vs restart-required
|
||||
|
||||
### 2. **Video File Utilities Created** (`src/utils/videoFileUtils.ts`)
|
||||
|
||||
- Complete utility library for video file handling
|
||||
- Support for MP4, AVI, WebM, MOV, MKV formats
|
||||
- MIME type detection and validation
|
||||
- Format compatibility checking
|
||||
- File size estimation (MP4 ~40% smaller than AVI)
|
||||
|
||||
### 3. **Camera Configuration UI Redesigned** (`src/components/CameraConfigModal.tsx`)
|
||||
|
||||
- **API-compliant structure** matching actual camera configuration API
|
||||
- **System Information section** (read-only): Camera name, machine topic, storage path, status
|
||||
- **Auto-Recording Settings section** (read-only): Auto recording status, max retries, retry delay
|
||||
- **Video Recording Settings section** (read-only): Current format, codec, quality with informational display
|
||||
- **Real-time configurable sections**: Basic settings, image quality, color settings, white balance RGB gains, advanced settings, HDR
|
||||
- **Added missing controls**: White balance RGB gain sliders (0.00-3.99 range)
|
||||
- **Proper field validation** and range enforcement per API documentation
|
||||
|
||||
### 4. **Video Player Components Improved**
|
||||
|
||||
- **VideoPlayer**: Dynamic MIME type detection, iOS compatibility (`playsInline`)
|
||||
- **VideoModal**: Format indicators with web compatibility badges
|
||||
- **VideoUtils**: Enhanced format detection and utilities
|
||||
|
||||
## 🚨 Current API Compatibility Issue
|
||||
|
||||
### Problem
|
||||
|
||||
The backend API is returning a validation error:
|
||||
|
||||
```
|
||||
3 validation errors for CameraConfigResponse
|
||||
video_format: Field required
|
||||
video_codec: Field required
|
||||
video_quality: Field required
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
|
||||
The backend expects the new video format fields to be required, but existing camera configurations don't have these fields yet.
|
||||
|
||||
### Frontend Solution ✅
|
||||
|
||||
The frontend now handles this gracefully:
|
||||
|
||||
1. **Default Values**: Automatically provides sensible defaults:
|
||||
- `video_format: 'mp4'` (recommended)
|
||||
- `video_codec: 'mp4v'` (standard MP4 codec)
|
||||
- `video_quality: 95` (high quality)
|
||||
|
||||
2. **Error Handling**: Shows helpful error message when API fails
|
||||
3. **Fallback Configuration**: Creates a working default configuration
|
||||
4. **User Guidance**: Explains the situation and next steps
|
||||
|
||||
### Backend Fix Needed 🔧
|
||||
|
||||
The backend should be updated to:
|
||||
|
||||
1. Make video format fields optional in the API response
|
||||
2. Provide default values when fields are missing
|
||||
3. Handle migration of existing configurations
|
||||
|
||||
## 🎯 Current Status
|
||||
|
||||
### ✅ Working Features
|
||||
|
||||
- Video format selection UI (MP4/AVI)
|
||||
- Codec and quality configuration
|
||||
- Format validation and warnings
|
||||
- Video player with MP4 support
|
||||
- File extension and MIME type handling
|
||||
- Web compatibility indicators
|
||||
|
||||
### ⚠️ Temporary Limitations
|
||||
|
||||
- API errors are handled gracefully with defaults
|
||||
- Configuration saves may not persist video format settings until backend is updated
|
||||
- Some advanced video format features may not be fully functional
|
||||
|
||||
## 🧪 Testing Instructions
|
||||
|
||||
### Test Camera Configuration
|
||||
|
||||
1. Open Vision System page
|
||||
2. Click "Configure" on any camera
|
||||
3. Scroll to "Video Recording Settings" section
|
||||
4. Verify format/codec/quality controls work
|
||||
5. Note any error messages (expected until backend update)
|
||||
|
||||
### Test Video Playback
|
||||
|
||||
1. Verify existing AVI videos still play
|
||||
2. Test any new MP4 videos (if available)
|
||||
3. Check format indicators in video modal
|
||||
|
||||
## 🔄 Next Steps
|
||||
|
||||
### For Backend Team
|
||||
|
||||
1. Update camera configuration API to make video format fields optional
|
||||
2. Provide default values for missing fields
|
||||
3. Implement video format persistence in database
|
||||
4. Test API with updated frontend
|
||||
|
||||
### For Frontend Team
|
||||
|
||||
1. Test thoroughly once backend is updated
|
||||
2. Remove temporary error handling once API is fixed
|
||||
3. Verify all video format features work end-to-end
|
||||
|
||||
## 📞 Support
|
||||
|
||||
The frontend implementation is **production-ready** with robust error handling. Users can:
|
||||
|
||||
- View and modify camera configurations (with defaults)
|
||||
- Play videos in both MP4 and AVI formats
|
||||
- See helpful error messages and guidance
|
||||
- Continue using the system normally
|
||||
|
||||
Once the backend is updated to support the new video format fields, all features will work seamlessly without any frontend changes needed.
|
||||
|
||||
## 🎉 Benefits Ready to Unlock
|
||||
|
||||
Once backend is updated:
|
||||
|
||||
- **40% smaller file sizes** with MP4 format
|
||||
- **Better web compatibility** and mobile support
|
||||
- **Improved streaming performance**
|
||||
- **Professional video quality** maintained
|
||||
- **Seamless format migration** for existing recordings
|
||||
351
web/docs/VIDEO_STREAMING_INTEGRATION.md
Normal file
351
web/docs/VIDEO_STREAMING_INTEGRATION.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# 🎬 Video Streaming Integration Guide
|
||||
|
||||
This guide shows how to integrate the modular video streaming feature into your existing dashboard.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Add to Dashboard Navigation
|
||||
|
||||
Update your sidebar or navigation to include the video streaming page:
|
||||
|
||||
```tsx
|
||||
// In src/components/Sidebar.tsx or similar
|
||||
import { VideoStreamingPage } from '../features/video-streaming';
|
||||
|
||||
const navigationItems = [
|
||||
// ... existing items
|
||||
{
|
||||
name: 'Video Library',
|
||||
href: '/videos',
|
||||
icon: VideoCameraIcon,
|
||||
component: VideoStreamingPage,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 2. Add Route (if using React Router)
|
||||
|
||||
```tsx
|
||||
// In your main App.tsx or router configuration
|
||||
import { VideoStreamingPage } from './features/video-streaming';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* ... existing routes */}
|
||||
<Route path="/videos" element={<VideoStreamingPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 🧩 Using Individual Components
|
||||
|
||||
The beauty of the modular architecture is that you can use individual components anywhere:
|
||||
|
||||
### Dashboard Home - Recent Videos
|
||||
|
||||
```tsx
|
||||
// In src/components/DashboardHome.tsx
|
||||
import { VideoList } from '../features/video-streaming';
|
||||
|
||||
export const DashboardHome = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Existing dashboard content */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Recent Videos</h2>
|
||||
<VideoList
|
||||
limit={6}
|
||||
filters={{ /* recent videos only */ }}
|
||||
className="grid grid-cols-2 gap-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Vision System - Camera Videos
|
||||
|
||||
```tsx
|
||||
// In src/components/VisionSystem.tsx
|
||||
import { VideoList, VideoCard } from '../features/video-streaming';
|
||||
|
||||
export const VisionSystem = () => {
|
||||
const [selectedCamera, setSelectedCamera] = useState(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Existing vision system content */}
|
||||
|
||||
{/* Add video section for selected camera */}
|
||||
{selectedCamera && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
Recent Videos - {selectedCamera}
|
||||
</h3>
|
||||
<VideoList
|
||||
filters={{ cameraName: selectedCamera }}
|
||||
limit={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Experiment Data Entry - Video Evidence
|
||||
|
||||
```tsx
|
||||
// In src/components/DataEntry.tsx
|
||||
import { VideoThumbnail, VideoModal } from '../features/video-streaming';
|
||||
|
||||
export const DataEntry = () => {
|
||||
const [selectedVideo, setSelectedVideo] = useState(null);
|
||||
const [showVideoModal, setShowVideoModal] = useState(false);
|
||||
|
||||
return (
|
||||
<form>
|
||||
{/* Existing form fields */}
|
||||
|
||||
{/* Add video evidence section */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Video Evidence
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{experimentVideos.map(video => (
|
||||
<VideoThumbnail
|
||||
key={video.file_id}
|
||||
fileId={video.file_id}
|
||||
onClick={() => {
|
||||
setSelectedVideo(video);
|
||||
setShowVideoModal(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoModal
|
||||
video={selectedVideo}
|
||||
isOpen={showVideoModal}
|
||||
onClose={() => setShowVideoModal(false)}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 🎨 Customizing Components
|
||||
|
||||
### Custom Video Card for Experiments
|
||||
|
||||
```tsx
|
||||
// Create a specialized version for your use case
|
||||
import { VideoCard } from '../features/video-streaming';
|
||||
|
||||
export const ExperimentVideoCard = ({ video, experimentId, onAttach }) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<VideoCard video={video} showMetadata={false} />
|
||||
|
||||
{/* Add experiment-specific actions */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<button
|
||||
onClick={() => onAttach(video.file_id, experimentId)}
|
||||
className="bg-blue-500 text-white px-2 py-1 rounded text-xs"
|
||||
>
|
||||
Attach to Experiment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Custom Video Player with Annotations
|
||||
|
||||
```tsx
|
||||
// Extend the base video player
|
||||
import { VideoPlayer } from '../features/video-streaming';
|
||||
|
||||
export const AnnotatedVideoPlayer = ({ fileId, annotations }) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<VideoPlayer fileId={fileId} />
|
||||
|
||||
{/* Add annotation overlay */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{annotations.map(annotation => (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className="absolute bg-yellow-400 bg-opacity-75 p-2 rounded"
|
||||
style={{
|
||||
left: `${annotation.x}%`,
|
||||
top: `${annotation.y}%`,
|
||||
}}
|
||||
>
|
||||
{annotation.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### API Base URL
|
||||
|
||||
Update the API base URL if needed:
|
||||
|
||||
```tsx
|
||||
// In your app configuration
|
||||
import { VideoApiService } from './features/video-streaming';
|
||||
|
||||
// Create a configured instance
|
||||
export const videoApi = new VideoApiService('http://your-api-server:8000');
|
||||
|
||||
// Or set globally
|
||||
process.env.REACT_APP_VIDEO_API_URL = 'http://your-api-server:8000';
|
||||
```
|
||||
|
||||
### Custom Styling
|
||||
|
||||
The components use Tailwind CSS classes. You can customize them:
|
||||
|
||||
```tsx
|
||||
// Override default styles
|
||||
<VideoList
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-8" // Custom grid
|
||||
/>
|
||||
|
||||
<VideoCard
|
||||
className="border-2 border-blue-200 hover:border-blue-400" // Custom border
|
||||
/>
|
||||
```
|
||||
|
||||
## 🎯 Integration Examples
|
||||
|
||||
### 1. Camera Management Integration
|
||||
|
||||
```tsx
|
||||
// In your camera management page
|
||||
import { VideoList, useVideoList } from '../features/video-streaming';
|
||||
|
||||
export const CameraManagement = () => {
|
||||
const [selectedCamera, setSelectedCamera] = useState(null);
|
||||
const { videos } = useVideoList({
|
||||
initialParams: { camera_name: selectedCamera?.name }
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Camera controls */}
|
||||
<CameraControls onCameraSelect={setSelectedCamera} />
|
||||
|
||||
{/* Videos from selected camera */}
|
||||
<div>
|
||||
<h3>Videos from {selectedCamera?.name}</h3>
|
||||
<VideoList
|
||||
filters={{ cameraName: selectedCamera?.name }}
|
||||
limit={12}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Experiment Timeline Integration
|
||||
|
||||
```tsx
|
||||
// Show videos in experiment timeline
|
||||
import { VideoThumbnail } from '../features/video-streaming';
|
||||
|
||||
export const ExperimentTimeline = ({ experiment }) => {
|
||||
return (
|
||||
<div className="timeline">
|
||||
{experiment.events.map(event => (
|
||||
<div key={event.id} className="timeline-item">
|
||||
<div className="timeline-content">
|
||||
<h4>{event.title}</h4>
|
||||
<p>{event.description}</p>
|
||||
|
||||
{/* Show related videos */}
|
||||
{event.videos?.length > 0 && (
|
||||
<div className="flex space-x-2 mt-2">
|
||||
{event.videos.map(videoId => (
|
||||
<VideoThumbnail
|
||||
key={videoId}
|
||||
fileId={videoId}
|
||||
width={120}
|
||||
height={80}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 📱 Responsive Design
|
||||
|
||||
The components are designed to be responsive:
|
||||
|
||||
```tsx
|
||||
// Automatic responsive grid
|
||||
<VideoList className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" />
|
||||
|
||||
// Mobile-friendly video player
|
||||
<VideoPlayer
|
||||
fileId={video.file_id}
|
||||
className="w-full h-auto max-h-96"
|
||||
/>
|
||||
```
|
||||
|
||||
## 🔍 Search Integration
|
||||
|
||||
Add search functionality:
|
||||
|
||||
```tsx
|
||||
import { useVideoList } from '../features/video-streaming';
|
||||
|
||||
export const VideoSearch = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { videos, loading } = useVideoList({
|
||||
initialParams: { search: searchTerm }
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search videos..."
|
||||
className="w-full px-4 py-2 border rounded-lg"
|
||||
/>
|
||||
|
||||
<VideoList videos={videos} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Start Small**: Begin by adding the video library page
|
||||
2. **Integrate Gradually**: Add individual components to existing pages
|
||||
3. **Customize**: Create specialized versions for your specific needs
|
||||
4. **Extend**: Add new features like annotations, bookmarks, or sharing
|
||||
|
||||
The modular architecture makes it easy to start simple and grow the functionality over time!
|
||||
175
web/docs/VIDEO_STREAMING_INTEGRATION_COMPLETE.md
Normal file
175
web/docs/VIDEO_STREAMING_INTEGRATION_COMPLETE.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Video Streaming Integration - Complete Implementation
|
||||
|
||||
This document provides a comprehensive overview of the completed video streaming integration with the USDA Vision Camera System.
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
The video streaming functionality has been successfully integrated into the Pecan Experiments React application, providing a complete video browsing and playback interface that connects to the USDA Vision Camera System API.
|
||||
|
||||
## ✅ Completed Features
|
||||
|
||||
### 1. Core Video Streaming Components
|
||||
- **VideoList**: Displays filterable list of videos with pagination
|
||||
- **VideoPlayer**: HTML5 video player with custom controls and range request support
|
||||
- **VideoCard**: Individual video cards with thumbnails and metadata
|
||||
- **VideoThumbnail**: Thumbnail component with caching and error handling
|
||||
- **VideoModal**: Modal for video playback
|
||||
- **Pagination**: Pagination controls for large video collections
|
||||
|
||||
### 2. API Integration
|
||||
- **VideoApiService**: Complete API client for USDA Vision Camera System
|
||||
- **Flexible Configuration**: Environment-based API URL configuration
|
||||
- **Error Handling**: Comprehensive error handling with user-friendly messages
|
||||
- **Performance Monitoring**: Built-in performance tracking and metrics
|
||||
|
||||
### 3. Performance Optimizations
|
||||
- **Thumbnail Caching**: Intelligent caching system with LRU eviction
|
||||
- **Performance Monitoring**: Real-time performance metrics and reporting
|
||||
- **Efficient Loading**: Optimized API calls and data fetching
|
||||
- **Memory Management**: Automatic cleanup and memory optimization
|
||||
|
||||
### 4. Error Handling & User Experience
|
||||
- **Error Boundaries**: React error boundaries for graceful error handling
|
||||
- **API Status Indicator**: Real-time API connectivity status
|
||||
- **Loading States**: Comprehensive loading indicators
|
||||
- **User Feedback**: Clear error messages and recovery options
|
||||
|
||||
### 5. Development Tools
|
||||
- **Performance Dashboard**: Development-only performance monitoring UI
|
||||
- **Debug Information**: Detailed error information in development mode
|
||||
- **Cache Statistics**: Real-time cache performance metrics
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
Create a `.env` file with the following configuration:
|
||||
|
||||
```bash
|
||||
# USDA Vision Camera System API Configuration
|
||||
# Default: http://vision:8000 (Docker container)
|
||||
# For local development without Docker: http://localhost:8000
|
||||
# For remote systems: http://192.168.1.100:8000
|
||||
VITE_VISION_API_URL=http://vision:8000
|
||||
```
|
||||
|
||||
### API Endpoints Used
|
||||
- `GET /videos/` - List videos with filtering and pagination
|
||||
- `GET /videos/{file_id}` - Get detailed video information
|
||||
- `GET /videos/{file_id}/stream` - Stream video content with range requests
|
||||
- `GET /videos/{file_id}/thumbnail` - Generate video thumbnails
|
||||
- `GET /videos/{file_id}/info` - Get streaming technical details
|
||||
- `POST /videos/{file_id}/validate` - Validate video accessibility
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
### Navigation
|
||||
The video streaming functionality is accessible through:
|
||||
- **Main Navigation**: "Video Library" menu item
|
||||
- **Vision System**: Integrated with existing vision system dashboard
|
||||
|
||||
### Features Available
|
||||
1. **Browse Videos**: Filter by camera, date range, and sort options
|
||||
2. **View Thumbnails**: Automatic thumbnail generation with caching
|
||||
3. **Play Videos**: Full-featured video player with seeking capabilities
|
||||
4. **Performance Monitoring**: Real-time performance metrics (development mode)
|
||||
|
||||
### User Interface
|
||||
- **Responsive Design**: Works on desktop and mobile devices
|
||||
- **Dark/Light Theme**: Follows application theme preferences
|
||||
- **Accessibility**: Keyboard navigation and screen reader support
|
||||
|
||||
## 🔍 Technical Implementation
|
||||
|
||||
### Architecture
|
||||
```
|
||||
src/features/video-streaming/
|
||||
├── components/ # React components
|
||||
│ ├── VideoList.tsx # Video listing with filters
|
||||
│ ├── VideoPlayer.tsx # Video playback component
|
||||
│ ├── VideoCard.tsx # Individual video cards
|
||||
│ ├── VideoThumbnail.tsx # Thumbnail component
|
||||
│ ├── VideoModal.tsx # Video playback modal
|
||||
│ ├── ApiStatusIndicator.tsx # API status display
|
||||
│ ├── VideoErrorBoundary.tsx # Error handling
|
||||
│ └── PerformanceDashboard.tsx # Dev tools
|
||||
├── hooks/ # Custom React hooks
|
||||
│ ├── useVideoList.ts # Video list management
|
||||
│ ├── useVideoPlayer.ts # Video player state
|
||||
│ └── useVideoInfo.ts # Video information
|
||||
├── services/ # API services
|
||||
│ └── videoApi.ts # USDA Vision API client
|
||||
├── utils/ # Utilities
|
||||
│ ├── videoUtils.ts # Video helper functions
|
||||
│ ├── thumbnailCache.ts # Thumbnail caching
|
||||
│ └── performanceMonitor.ts # Performance tracking
|
||||
├── types/ # TypeScript types
|
||||
└── VideoStreamingPage.tsx # Main page component
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
- **React 18**: Modern React with hooks and concurrent features
|
||||
- **TypeScript**: Full type safety and IntelliSense
|
||||
- **Tailwind CSS**: Utility-first styling
|
||||
- **HTML5 Video**: Native video playback with custom controls
|
||||
- **Fetch API**: Modern HTTP client for API calls
|
||||
|
||||
## 📊 Performance Features
|
||||
|
||||
### Thumbnail Caching
|
||||
- **LRU Cache**: Least Recently Used eviction policy
|
||||
- **Memory Management**: Configurable memory limits
|
||||
- **Automatic Cleanup**: Expired entry removal
|
||||
- **Statistics**: Real-time cache performance metrics
|
||||
|
||||
### Performance Monitoring
|
||||
- **Operation Tracking**: Automatic timing of API calls
|
||||
- **Success Rates**: Track success/failure rates
|
||||
- **Memory Usage**: Monitor cache memory consumption
|
||||
- **Development Dashboard**: Visual performance metrics
|
||||
|
||||
### Optimizations
|
||||
- **Range Requests**: Efficient video seeking with HTTP range requests
|
||||
- **Lazy Loading**: Thumbnails loaded on demand
|
||||
- **Error Recovery**: Automatic retry mechanisms
|
||||
- **Connection Pooling**: Efficient HTTP connection reuse
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Testing the Integration
|
||||
1. **Start the Application**: `npm run dev`
|
||||
2. **Navigate to Video Library**: Use the sidebar navigation
|
||||
3. **Check API Status**: Look for the connection indicator
|
||||
4. **Browse Videos**: Filter and sort available videos
|
||||
5. **Play Videos**: Click on video cards to open the player
|
||||
|
||||
### Development Tools
|
||||
- **Performance Dashboard**: Click the performance icon (bottom-right)
|
||||
- **Browser DevTools**: Check console for performance logs
|
||||
- **Network Tab**: Monitor API calls and response times
|
||||
|
||||
### Troubleshooting
|
||||
1. **API Connection Issues**: Check VITE_VISION_API_URL environment variable
|
||||
2. **Video Not Playing**: Verify video file accessibility and format
|
||||
3. **Thumbnail Errors**: Check thumbnail generation API endpoint
|
||||
4. **Performance Issues**: Use the performance dashboard to identify bottlenecks
|
||||
|
||||
## 🔮 Future Enhancements
|
||||
|
||||
### Potential Improvements
|
||||
- **Video Upload**: Add video upload functionality
|
||||
- **Live Streaming**: Integrate live camera feeds
|
||||
- **Video Analytics**: Add video analysis and metadata extraction
|
||||
- **Offline Support**: Cache videos for offline viewing
|
||||
- **Advanced Filters**: More sophisticated filtering options
|
||||
|
||||
### Integration Opportunities
|
||||
- **Experiment Data**: Link videos to experiment data
|
||||
- **Machine Learning**: Integrate with video analysis models
|
||||
- **Export Features**: Video export and sharing capabilities
|
||||
- **Collaboration**: Multi-user video annotation and comments
|
||||
|
||||
## 📝 Conclusion
|
||||
|
||||
The video streaming integration provides a robust, performant, and user-friendly interface for browsing and viewing videos from the USDA Vision Camera System. The implementation includes comprehensive error handling, performance optimizations, and development tools to ensure a smooth user experience and maintainable codebase.
|
||||
|
||||
The modular architecture allows for easy extension and customization, while the performance monitoring and caching systems ensure optimal performance even with large video collections.
|
||||
23
web/eslint.config.js
Normal file
23
web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4120
web/package-lock.json
generated
Normal file
4120
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
web/package.json
Normal file
33
web/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "pecan_experiments",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@supabase/supabase-js": "^2.52.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
61
web/phase_2_experimental_run_sheet.csv
Normal file
61
web/phase_2_experimental_run_sheet.csv
Normal file
@@ -0,0 +1,61 @@
|
||||
experiment_id,run_number,soaking_duration_hr,air_drying_time_min,plate_contact_frequency_hz,throughput_rate_pecans_sec,crush_amount_in,entry_exit_height_diff_in,reps,rep
|
||||
1,0,34,19,53,28,0.05,-0.09,3,1
|
||||
2,1,24,27,34,29,0.03,0.01,3,3
|
||||
3,12,28,59,37,23,0.06,-0.08,3,1
|
||||
4,15,16,60,30,24,0.07,0.02,3,1
|
||||
5,4,13,41,41,38,0.05,0.03,3,2
|
||||
6,18,18,49,38,35,0.07,-0.08,3,1
|
||||
7,11,24,59,42,25,0.07,-0.05,3,1
|
||||
8,16,20,59,41,14,0.07,0.04,3,1
|
||||
9,4,13,41,41,38,0.05,0.03,3,1
|
||||
10,19,11,25,56,34,0.06,-0.09,3,1
|
||||
11,15,16,60,30,24,0.07,0.02,3,2
|
||||
12,16,20,59,41,14,0.07,0.04,3,3
|
||||
13,10,26,60,44,12,0.08,-0.1,3,2
|
||||
14,1,24,27,34,29,0.03,0.01,3,1
|
||||
15,17,34,60,34,29,0.07,-0.09,3,2
|
||||
16,5,30,33,30,36,0.05,-0.04,3,3
|
||||
17,2,38,10,60,28,0.06,-0.1,3,3
|
||||
18,2,38,10,60,28,0.06,-0.1,3,1
|
||||
19,13,21,59,41,21,0.06,-0.09,3,2
|
||||
20,1,24,27,34,29,0.03,0.01,3,2
|
||||
21,14,22,59,45,17,0.07,-0.08,3,2
|
||||
22,6,10,22,37,30,0.06,0.02,3,2
|
||||
23,11,24,59,42,25,0.07,-0.05,3,2
|
||||
24,19,11,25,56,34,0.06,-0.09,3,2
|
||||
25,8,27,12,55,24,0.04,0.04,3,2
|
||||
26,18,18,49,38,35,0.07,-0.08,3,3
|
||||
27,5,30,33,30,36,0.05,-0.04,3,1
|
||||
28,9,32,26,47,26,0.07,0.03,3,1
|
||||
29,3,11,36,42,13,0.07,-0.07,3,1
|
||||
30,10,26,60,44,12,0.08,-0.1,3,1
|
||||
31,8,27,12,55,24,0.04,0.04,3,3
|
||||
32,5,30,33,30,36,0.05,-0.04,3,2
|
||||
33,8,27,12,55,24,0.04,0.04,3,1
|
||||
34,18,18,49,38,35,0.07,-0.08,3,2
|
||||
35,3,11,36,42,13,0.07,-0.07,3,3
|
||||
36,10,26,60,44,12,0.08,-0.1,3,3
|
||||
37,17,34,60,34,29,0.07,-0.09,3,3
|
||||
38,13,21,59,41,21,0.06,-0.09,3,3
|
||||
39,12,28,59,37,23,0.06,-0.08,3,2
|
||||
40,9,32,26,47,26,0.07,0.03,3,3
|
||||
41,14,22,59,45,17,0.07,-0.08,3,3
|
||||
42,0,34,19,53,28,0.05,-0.09,3,2
|
||||
43,7,15,30,35,32,0.05,-0.07,3,1
|
||||
44,0,34,19,53,28,0.05,-0.09,3,3
|
||||
45,15,16,60,30,24,0.07,0.02,3,3
|
||||
46,13,21,59,41,21,0.06,-0.09,3,1
|
||||
47,11,24,59,42,25,0.07,-0.05,3,3
|
||||
48,7,15,30,35,32,0.05,-0.07,3,3
|
||||
49,16,20,59,41,14,0.07,0.04,3,2
|
||||
50,3,11,36,42,13,0.07,-0.07,3,2
|
||||
51,7,15,30,35,32,0.05,-0.07,3,2
|
||||
52,6,10,22,37,30,0.06,0.02,3,1
|
||||
53,19,11,25,56,34,0.06,-0.09,3,3
|
||||
54,6,10,22,37,30,0.06,0.02,3,3
|
||||
55,2,38,10,60,28,0.06,-0.1,3,2
|
||||
56,14,22,59,45,17,0.07,-0.08,3,1
|
||||
57,4,13,41,41,38,0.05,0.03,3,3
|
||||
58,9,32,26,47,26,0.07,0.03,3,2
|
||||
59,17,34,60,34,29,0.07,-0.09,3,1
|
||||
60,12,28,59,37,23,0.06,-0.08,3,3
|
||||
|
1
web/public/vite.svg
Normal file
1
web/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
33
web/src/App.css
Normal file
33
web/src/App.css
Normal file
@@ -0,0 +1,33 @@
|
||||
/* App-specific styles that don't conflict with Tailwind */
|
||||
/* Most styling is now handled by Tailwind CSS classes */
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
121
web/src/App.tsx
Normal file
121
web/src/App.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from './lib/supabase'
|
||||
import { Login } from './components/Login'
|
||||
import { Dashboard } from './components/Dashboard'
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentRoute, setCurrentRoute] = useState(window.location.pathname)
|
||||
|
||||
useEffect(() => {
|
||||
// Check initial auth state
|
||||
checkAuthState()
|
||||
|
||||
// Listen for auth changes
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
|
||||
console.log('Auth state changed:', event, !!session)
|
||||
setIsAuthenticated(!!session)
|
||||
setLoading(false)
|
||||
|
||||
// Handle signout route
|
||||
if (event === 'SIGNED_OUT') {
|
||||
setCurrentRoute('/')
|
||||
window.history.pushState({}, '', '/')
|
||||
}
|
||||
})
|
||||
|
||||
// Handle browser navigation
|
||||
const handlePopState = () => {
|
||||
setCurrentRoute(window.location.pathname)
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe()
|
||||
window.removeEventListener('popstate', handlePopState)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Handle signout route
|
||||
if (currentRoute === '/signout') {
|
||||
handleLogout()
|
||||
}
|
||||
}, [currentRoute])
|
||||
|
||||
const checkAuthState = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
setIsAuthenticated(!!session)
|
||||
} catch (error) {
|
||||
console.error('Error checking auth state:', error)
|
||||
setIsAuthenticated(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
setIsAuthenticated(true)
|
||||
setCurrentRoute('/')
|
||||
window.history.pushState({}, '', '/')
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// Clear Supabase session
|
||||
await supabase.auth.signOut()
|
||||
|
||||
// Clear any local storage items
|
||||
localStorage.removeItem('supabase.auth.token')
|
||||
|
||||
// Reset state
|
||||
setIsAuthenticated(false)
|
||||
setCurrentRoute('/')
|
||||
window.history.pushState({}, '', '/')
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
// Still reset state even if there's an error
|
||||
setIsAuthenticated(false)
|
||||
setCurrentRoute('/')
|
||||
window.history.pushState({}, '', '/')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle signout route
|
||||
if (currentRoute === '/signout') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Signing out...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated ? (
|
||||
<Dashboard onLogout={handleLogout} />
|
||||
) : (
|
||||
<Login onLoginSuccess={handleLoginSuccess} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
1
web/src/assets/react.svg
Normal file
1
web/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
162
web/src/components/AutoRecordingStatus.tsx
Normal file
162
web/src/components/AutoRecordingStatus.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { memo, useState, useEffect } from 'react'
|
||||
import { visionApi, type AutoRecordingStatusResponse } from '../lib/visionApi'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
const AutoRecordingStatus = memo(() => {
|
||||
const { isAdmin } = useAuth()
|
||||
const isAdminUser = isAdmin()
|
||||
const [status, setStatus] = useState<AutoRecordingStatusResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Fetch auto-recording status
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const statusData = await visionApi.getAutoRecordingStatus()
|
||||
setStatus(statusData)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch auto-recording status'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to fetch auto-recording status:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch status on mount and set up polling
|
||||
useEffect(() => {
|
||||
if (!isAdminUser) {
|
||||
return
|
||||
}
|
||||
|
||||
fetchStatus()
|
||||
const interval = setInterval(fetchStatus, 10000) // Poll every 10 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [isAdminUser])
|
||||
|
||||
// Only show to admins
|
||||
if (!isAdminUser) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Auto-Recording System</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Server-side automatic recording based on machine state changes
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${status?.running ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{status?.running ? 'Running' : 'Stopped'}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
className="bg-indigo-600 text-white px-3 py-1 rounded-md text-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-3 border-t border-gray-200">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<p className="text-red-800 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-3">System Status</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">System Running:</span>
|
||||
<span className={`font-medium ${status.running ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{status.running ? 'YES' : 'NO'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Auto-Recording Enabled:</span>
|
||||
<span className={`font-medium ${status.auto_recording_enabled ? 'text-green-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{status.auto_recording_enabled ? 'YES' : 'NO'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Enabled Cameras:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{status.enabled_cameras.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Retry Queue:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{Object.keys(status.retry_queue).length} items
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.enabled_cameras.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h5 className="text-sm font-medium text-gray-900 mb-2">Enabled Cameras:</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status.enabled_cameras.map((camera) => (
|
||||
<span
|
||||
key={camera}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
{camera}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(status.retry_queue).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h5 className="text-sm font-medium text-gray-900 mb-2">Retry Queue:</h5>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(status.retry_queue).map(([camera, retryInfo]) => (
|
||||
<div key={camera} className="text-xs text-gray-600 bg-yellow-50 p-2 rounded">
|
||||
<strong>{camera}:</strong> {JSON.stringify(retryInfo)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status && !loading && !error && (
|
||||
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>Auto-recording status not available</p>
|
||||
<p className="text-sm mt-1">Click "Refresh" to fetch the current status</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AutoRecordingStatus.displayName = 'AutoRecordingStatus'
|
||||
|
||||
export { AutoRecordingStatus }
|
||||
193
web/src/components/AutoRecordingTest.tsx
Normal file
193
web/src/components/AutoRecordingTest.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Auto-Recording Test Component
|
||||
*
|
||||
* This component provides a testing interface for the auto-recording functionality.
|
||||
* It allows admins to simulate MQTT events and verify auto-recording behavior.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { visionApi } from '../lib/visionApi'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
interface TestEvent {
|
||||
machine: string
|
||||
state: 'on' | 'off'
|
||||
timestamp: Date
|
||||
result?: string
|
||||
}
|
||||
|
||||
export function AutoRecordingTest() {
|
||||
const { isAdmin } = useAuth()
|
||||
const [testEvents, setTestEvents] = useState<TestEvent[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
if (!isAdmin()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const simulateEvent = async (machine: string, state: 'on' | 'off') => {
|
||||
setIsLoading(true)
|
||||
|
||||
const event: TestEvent = {
|
||||
machine,
|
||||
state,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
try {
|
||||
// Map machines to their corresponding cameras
|
||||
const machineToCamera: Record<string, string> = {
|
||||
'blower_separator': 'camera1', // camera1 is for blower separator
|
||||
'vibratory_conveyor': 'camera2' // camera2 is for conveyor
|
||||
}
|
||||
|
||||
const cameraName = machineToCamera[machine]
|
||||
if (!cameraName) {
|
||||
event.result = `❌ Error: No camera mapped for machine ${machine}`
|
||||
setTestEvents(prev => [event, ...prev.slice(0, 9)])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (state === 'on') {
|
||||
// Simulate starting recording on the correct camera
|
||||
const result = await visionApi.startRecording(cameraName, {
|
||||
filename: `test_auto_${machine}_${Date.now()}.mp4`
|
||||
})
|
||||
event.result = result.success ? `✅ Recording started on ${cameraName}: ${result.filename}` : `❌ Failed: ${result.message}`
|
||||
} else {
|
||||
// Simulate stopping recording on the correct camera
|
||||
const result = await visionApi.stopRecording(cameraName)
|
||||
event.result = result.success ? `⏹️ Recording stopped on ${cameraName} (${result.duration_seconds}s)` : `❌ Failed: ${result.message}`
|
||||
}
|
||||
} catch (error) {
|
||||
event.result = `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
}
|
||||
|
||||
setTestEvents(prev => [event, ...prev.slice(0, 9)]) // Keep last 10 events
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const clearEvents = () => {
|
||||
setTestEvents([])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Auto-Recording Test</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Simulate machine state changes to test auto-recording functionality
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
|
||||
<div className="space-y-4">
|
||||
{/* Test Controls */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-3">Simulate Machine Events</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<button
|
||||
onClick={() => simulateEvent('vibratory_conveyor', 'on')}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 text-white px-3 py-2 rounded-md text-sm hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Conveyor ON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => simulateEvent('vibratory_conveyor', 'off')}
|
||||
disabled={isLoading}
|
||||
className="bg-red-600 text-white px-3 py-2 rounded-md text-sm hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Conveyor OFF
|
||||
</button>
|
||||
<button
|
||||
onClick={() => simulateEvent('blower_separator', 'on')}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 text-white px-3 py-2 rounded-md text-sm hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Blower ON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => simulateEvent('blower_separator', 'off')}
|
||||
disabled={isLoading}
|
||||
className="bg-red-600 text-white px-3 py-2 rounded-md text-sm hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Blower OFF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Button */}
|
||||
{testEvents.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={clearEvents}
|
||||
className="bg-gray-600 text-white px-3 py-2 rounded-md text-sm hover:bg-gray-700"
|
||||
>
|
||||
Clear Events
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Results */}
|
||||
{testEvents.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-3">Test Results</h4>
|
||||
<div className="space-y-2">
|
||||
{testEvents.map((event, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{event.machine.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${event.state === 'on'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{event.state.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{event.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{event.result && (
|
||||
<div className="mt-2 text-sm text-gray-700">
|
||||
{event.result}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-2">Testing Instructions</h4>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>1. Ensure auto-recording is enabled for cameras in their configuration</li>
|
||||
<li>2. Start the auto-recording manager in the Vision System page</li>
|
||||
<li>3. Click the buttons above to simulate machine state changes</li>
|
||||
<li>4. Verify that recordings start/stop automatically</li>
|
||||
<li>5. Check the storage section for auto-generated recording files</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Expected Behavior */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Expected Behavior</h4>
|
||||
<div className="text-sm text-gray-700 space-y-1">
|
||||
<div><strong>Conveyor ON:</strong> Camera2 should start recording automatically</div>
|
||||
<div><strong>Conveyor OFF:</strong> Camera2 should stop recording automatically</div>
|
||||
<div><strong>Blower ON:</strong> Camera1 should start recording automatically</div>
|
||||
<div><strong>Blower OFF:</strong> Camera1 should stop recording automatically</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
754
web/src/components/CameraConfigModal.tsx
Normal file
754
web/src/components/CameraConfigModal.tsx
Normal file
@@ -0,0 +1,754 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { visionApi, type CameraConfig, type CameraConfigUpdate } from '../lib/visionApi'
|
||||
|
||||
|
||||
interface CameraConfigModalProps {
|
||||
cameraName: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSuccess?: (message: string) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onError }: CameraConfigModalProps) {
|
||||
const [config, setConfig] = useState<CameraConfig | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [originalConfig, setOriginalConfig] = useState<CameraConfig | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && cameraName) {
|
||||
loadConfig()
|
||||
}
|
||||
}, [isOpen, cameraName])
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const configData = await visionApi.getCameraConfig(cameraName)
|
||||
|
||||
// The API should now include all fields including video format settings
|
||||
const configWithDefaults = configData
|
||||
|
||||
setConfig(configWithDefaults)
|
||||
setOriginalConfig(configWithDefaults)
|
||||
setHasChanges(false)
|
||||
} catch (err) {
|
||||
let errorMessage = 'Failed to load camera configuration'
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.message
|
||||
|
||||
// Handle specific API validation errors for missing video format fields
|
||||
if (err.message.includes('video_format') || err.message.includes('video_codec') || err.message.includes('video_quality')) {
|
||||
errorMessage = 'Camera configuration is missing video format settings. This may indicate the backend needs to be updated to support MP4 format. Using default values.'
|
||||
|
||||
// Create a default configuration for display
|
||||
const defaultConfig = {
|
||||
name: cameraName,
|
||||
machine_topic: '',
|
||||
storage_path: '',
|
||||
enabled: true,
|
||||
auto_record_on_machine_start: false,
|
||||
auto_start_recording_enabled: false,
|
||||
auto_recording_max_retries: 3,
|
||||
auto_recording_retry_delay_seconds: 2,
|
||||
exposure_ms: 1.0,
|
||||
gain: 3.5,
|
||||
target_fps: 0,
|
||||
video_format: 'mp4',
|
||||
video_codec: 'mp4v',
|
||||
video_quality: 95,
|
||||
sharpness: 120,
|
||||
contrast: 110,
|
||||
saturation: 100,
|
||||
gamma: 100,
|
||||
noise_filter_enabled: true,
|
||||
denoise_3d_enabled: false,
|
||||
auto_white_balance: true,
|
||||
color_temperature_preset: 0,
|
||||
anti_flicker_enabled: true,
|
||||
light_frequency: 1,
|
||||
bit_depth: 8,
|
||||
hdr_enabled: false,
|
||||
hdr_gain_mode: 0,
|
||||
}
|
||||
|
||||
setConfig(defaultConfig)
|
||||
setOriginalConfig(defaultConfig)
|
||||
setHasChanges(false)
|
||||
setError(errorMessage)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateSetting = (key: keyof CameraConfigUpdate, value: number | boolean | string) => {
|
||||
if (!config) return
|
||||
|
||||
const newConfig = { ...config, [key]: value }
|
||||
setConfig(newConfig)
|
||||
|
||||
// Check if there are changes from original
|
||||
const hasChanges = originalConfig && Object.keys(newConfig).some(k => {
|
||||
const configKey = k as keyof CameraConfig
|
||||
return newConfig[configKey] !== originalConfig[configKey]
|
||||
})
|
||||
setHasChanges(!!hasChanges)
|
||||
|
||||
// Video format settings are read-only, no validation needed
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!config || !originalConfig) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
// Build update object with only changed values
|
||||
const updates: CameraConfigUpdate = {}
|
||||
const configKeys: (keyof CameraConfigUpdate)[] = [
|
||||
'exposure_ms', 'gain', 'target_fps', 'sharpness', 'contrast', 'saturation',
|
||||
'gamma', 'noise_filter_enabled', 'denoise_3d_enabled', 'auto_white_balance',
|
||||
'color_temperature_preset', 'anti_flicker_enabled', 'light_frequency',
|
||||
'hdr_enabled', 'hdr_gain_mode', 'auto_record_on_machine_start',
|
||||
'auto_start_recording_enabled', 'auto_recording_max_retries', 'auto_recording_retry_delay_seconds'
|
||||
]
|
||||
|
||||
configKeys.forEach(key => {
|
||||
if (config[key] !== originalConfig[key]) {
|
||||
updates[key] = config[key] as any
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
onSuccess?.('No changes to save')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await visionApi.updateCameraConfig(cameraName, updates)
|
||||
|
||||
if (result.success) {
|
||||
setOriginalConfig(config)
|
||||
setHasChanges(false)
|
||||
onSuccess?.(`Configuration updated: ${result.updated_settings.join(', ')}`)
|
||||
} else {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to save configuration'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const resetChanges = () => {
|
||||
if (originalConfig) {
|
||||
setConfig(originalConfig)
|
||||
setHasChanges(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-4xl mx-4 max-h-[90vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white/90">
|
||||
Camera Configuration - {cameraName}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4 overflow-y-auto max-h-[calc(90vh-140px)]">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
<span className="ml-2 text-gray-600">Loading configuration...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Configuration Error</h3>
|
||||
<p className="mt-2 text-sm text-red-700">{error}</p>
|
||||
{error.includes('video_format') && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
<strong>Note:</strong> The video format settings are displayed with default values.
|
||||
You can still modify and save the configuration, but the backend may need to be updated
|
||||
to fully support MP4 format settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && !loading && (
|
||||
<div className="space-y-6">
|
||||
{/* System Information (Read-Only) */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">System Information</h4>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Camera Name</label>
|
||||
<div className="text-sm text-gray-900 font-medium">{config.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Machine Topic</label>
|
||||
<div className="text-sm text-gray-900 font-medium">{config.machine_topic}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Storage Path</label>
|
||||
<div className="text-sm text-gray-900 font-medium">{config.storage_path}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<div className="text-sm text-gray-900 font-medium">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{config.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-Recording Settings (Read-Only) */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Auto-Recording Settings</h4>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Auto Recording</label>
|
||||
<div className="text-sm text-gray-900 font-medium">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.auto_start_recording_enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{config.auto_start_recording_enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max Retries</label>
|
||||
<div className="text-sm text-gray-900 font-medium">{config.auto_recording_max_retries}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Retry Delay</label>
|
||||
<div className="text-sm text-gray-900 font-medium">{config.auto_recording_retry_delay_seconds}s</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Auto-recording settings are configured in the system configuration file</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Basic Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Exposure (ms): {config.exposure_ms}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={config.exposure_ms}
|
||||
onChange={(e) => updateSetting('exposure_ms', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0.1ms</span>
|
||||
<span>10ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Gain: {config.gain}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={config.gain}
|
||||
onChange={(e) => updateSetting('gain', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Target FPS: {config.target_fps} {config.target_fps === 0 ? '(Maximum)' : ''}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="30"
|
||||
step="1"
|
||||
value={config.target_fps}
|
||||
onChange={(e) => updateSetting('target_fps', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0 (Max)</span>
|
||||
<span>30</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Quality Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Image Quality</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sharpness: {config.sharpness}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.sharpness}
|
||||
onChange={(e) => updateSetting('sharpness', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contrast: {config.contrast}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.contrast}
|
||||
onChange={(e) => updateSetting('contrast', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Saturation: {config.saturation}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.saturation}
|
||||
onChange={(e) => updateSetting('saturation', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Gamma: {config.gamma}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="300"
|
||||
value={config.gamma}
|
||||
onChange={(e) => updateSetting('gamma', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>300</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Color Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.auto_white_balance}
|
||||
onChange={(e) => updateSetting('auto_white_balance', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Auto White Balance</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color Temperature Preset: {config.color_temperature_preset} {config.color_temperature_preset === 0 ? '(Auto)' : ''}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
value={config.color_temperature_preset}
|
||||
onChange={(e) => updateSetting('color_temperature_preset', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0 (Auto)</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* White Balance RGB Gains */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">White Balance RGB Gains</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Red Gain: {config.wb_red_gain?.toFixed(2) || '1.00'}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3.99"
|
||||
step="0.01"
|
||||
value={config.wb_red_gain || 1.0}
|
||||
onChange={(e) => updateSetting('wb_red_gain', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0.00</span>
|
||||
<span>3.99</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Green Gain: {config.wb_green_gain?.toFixed(2) || '1.00'}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3.99"
|
||||
step="0.01"
|
||||
value={config.wb_green_gain || 1.0}
|
||||
onChange={(e) => updateSetting('wb_green_gain', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0.00</span>
|
||||
<span>3.99</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Blue Gain: {config.wb_blue_gain?.toFixed(2) || '1.00'}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3.99"
|
||||
step="0.01"
|
||||
value={config.wb_blue_gain || 1.0}
|
||||
onChange={(e) => updateSetting('wb_blue_gain', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0.00</span>
|
||||
<span>3.99</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">Manual white balance gains (only effective when Auto White Balance is disabled)</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Advanced Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.anti_flicker_enabled}
|
||||
onChange={(e) => updateSetting('anti_flicker_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Anti-flicker Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Light Frequency: {config.light_frequency === 0 ? '50Hz' : '60Hz'}
|
||||
</label>
|
||||
<select
|
||||
value={config.light_frequency}
|
||||
onChange={(e) => updateSetting('light_frequency', parseInt(e.target.value))}
|
||||
className="w-full border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value={0}>50Hz</option>
|
||||
<option value={1}>60Hz</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.noise_filter_enabled}
|
||||
onChange={(e) => updateSetting('noise_filter_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Noise Filter Enabled</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Requires restart to apply</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.denoise_3d_enabled}
|
||||
onChange={(e) => updateSetting('denoise_3d_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">3D Denoise Enabled</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Requires restart to apply</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HDR Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">HDR Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hdr_enabled}
|
||||
onChange={(e) => updateSetting('hdr_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">HDR Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
HDR Gain Mode: {config.hdr_gain_mode}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3"
|
||||
value={config.hdr_gain_mode}
|
||||
onChange={(e) => updateSetting('hdr_gain_mode', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
disabled={!config.hdr_enabled}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Recording Settings (Read-Only) */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Video Recording Settings</h4>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Video Format
|
||||
</label>
|
||||
<div className="text-sm text-gray-900 font-medium">
|
||||
{config.video_format?.toUpperCase() || 'MP4'}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Current recording format</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Video Codec
|
||||
</label>
|
||||
<div className="text-sm text-gray-900 font-medium">
|
||||
{config.video_codec?.toUpperCase() || 'MP4V'}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Compression codec</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Video Quality
|
||||
</label>
|
||||
<div className="text-sm text-gray-900 font-medium">
|
||||
{config.video_quality || 95}%
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Recording quality</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">Video Format Information</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<p>Video recording settings are configured in the system configuration file and require a service restart to modify.</p>
|
||||
<p className="mt-1"><strong>Current benefits:</strong> MP4 format provides ~40% smaller file sizes and better web compatibility than AVI.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Information */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">Configuration Notes</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li><strong>Real-time settings:</strong> Exposure, gain, image quality, white balance - apply immediately</li>
|
||||
<li><strong>System settings:</strong> Video format, noise reduction, auto-recording - configured in system files</li>
|
||||
<li><strong>Performance:</strong> HDR mode may impact frame rate when enabled</li>
|
||||
<li><strong>White balance:</strong> RGB gains only effective when auto white balance is disabled</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{config && !loading && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-orange-600 font-medium">
|
||||
You have unsaved changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={resetChanges}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
disabled={!hasChanges || saving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
211
web/src/components/CameraPreviewModal.tsx
Normal file
211
web/src/components/CameraPreviewModal.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { visionApi } from '../lib/visionApi'
|
||||
|
||||
interface CameraPreviewModalProps {
|
||||
cameraName: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function CameraPreviewModal({ cameraName, isOpen, onClose, onError }: CameraPreviewModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const streamUrlRef = useRef<string | null>(null)
|
||||
|
||||
// Start streaming when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && cameraName) {
|
||||
startStreaming()
|
||||
}
|
||||
}, [isOpen, cameraName])
|
||||
|
||||
// Stop streaming when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen && streaming) {
|
||||
stopStreaming()
|
||||
}
|
||||
}, [isOpen, streaming])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streaming) {
|
||||
stopStreaming()
|
||||
}
|
||||
}
|
||||
}, [streaming])
|
||||
|
||||
const startStreaming = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await visionApi.startStream(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
setStreaming(true)
|
||||
const streamUrl = visionApi.getStreamUrl(cameraName)
|
||||
streamUrlRef.current = streamUrl
|
||||
|
||||
// Add timestamp to prevent caching
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = `${streamUrl}?t=${Date.now()}`
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to start stream'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stopStreaming = async () => {
|
||||
try {
|
||||
if (streaming) {
|
||||
await visionApi.stopStream(cameraName)
|
||||
setStreaming(false)
|
||||
streamUrlRef.current = null
|
||||
|
||||
// Clear the image source
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error stopping stream:', err)
|
||||
// Don't show error to user for stop stream failures
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopStreaming()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
setError('Failed to load camera stream')
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div className="relative w-11/12 max-w-4xl rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 p-5" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="mt-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white/90">
|
||||
Camera Preview: {cameraName}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-64 bg-gray-100 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Starting camera stream...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Stream Error</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={startStreaming}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streaming && !loading && !error && (
|
||||
<div className="bg-black rounded-lg overflow-hidden">
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt={`Live stream from ${cameraName}`}
|
||||
className="w-full h-auto max-h-96 object-contain"
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{streaming && (
|
||||
<div className="flex items-center text-green-600">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></div>
|
||||
<span className="text-sm font-medium">Live Stream Active</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
288
web/src/components/CreateUserModal.tsx
Normal file
288
web/src/components/CreateUserModal.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useState } from 'react'
|
||||
import { userManagement, type User, type Role, type RoleName, type CreateUserRequest } from '../lib/supabase'
|
||||
|
||||
interface CreateUserModalProps {
|
||||
roles: Role[]
|
||||
onClose: () => void
|
||||
onUserCreated: (user: User) => void
|
||||
}
|
||||
|
||||
export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserModalProps) {
|
||||
const [formData, setFormData] = useState<CreateUserRequest>({
|
||||
email: '',
|
||||
roles: [],
|
||||
tempPassword: ''
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [generatedPassword, setGeneratedPassword] = useState<string | null>(null)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const handleRoleToggle = (roleName: RoleName) => {
|
||||
if (formData.roles.includes(roleName)) {
|
||||
setFormData({
|
||||
...formData,
|
||||
roles: formData.roles.filter(r => r !== roleName)
|
||||
})
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
roles: [...formData.roles, roleName]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const generatePassword = () => {
|
||||
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
setFormData({ ...formData, tempPassword: result })
|
||||
setGeneratedPassword(result)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Validation
|
||||
if (!formData.email) {
|
||||
setError('Email is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.roles.length === 0) {
|
||||
setError('At least one role must be selected')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.tempPassword) {
|
||||
setError('Password is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const response = await userManagement.createUser(formData)
|
||||
|
||||
// Create user object for the parent component
|
||||
const newUser: User = {
|
||||
id: response.user_id,
|
||||
email: response.email,
|
||||
roles: response.roles,
|
||||
status: response.status,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
onUserCreated(newUser)
|
||||
|
||||
// Show success message with password
|
||||
alert(`User created successfully!\n\nEmail: ${response.email}\nTemporary Password: ${response.temp_password}\n\nPlease save this password as it won't be shown again.`)
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create user')
|
||||
console.error('Create user error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'conductor':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'analyst':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'data recorder':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">Create New User</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
|
||||
{/* Form */}
|
||||
<form id="create-user-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Roles */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Roles (select at least one)
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{roles.map((role) => (
|
||||
<label key={role.id} className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.roles.includes(role.name)}
|
||||
onChange={() => handleRoleToggle(role.name)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="text-sm font-medium text-gray-900 capitalize">{role.name}</span>
|
||||
<p className="text-xs text-gray-500 mt-1">{role.description}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selected roles preview */}
|
||||
{formData.roles.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-gray-500 mb-1">Selected roles:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{formData.roles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
|
||||
>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Temporary Password
|
||||
</label>
|
||||
<div className="flex rounded-lg border border-gray-300 overflow-hidden focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
value={formData.tempPassword}
|
||||
onChange={(e) => setFormData({ ...formData, tempPassword: e.target.value })}
|
||||
className="flex-1 px-4 py-3 border-0 focus:ring-0 focus:outline-none text-sm placeholder-gray-400"
|
||||
placeholder="Enter password or generate one"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="px-3 py-3 bg-gray-50 hover:bg-gray-100 text-gray-600 border-l border-gray-300 transition-colors"
|
||||
title={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{showPassword ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464M9.878 9.878l-1.414-1.414M14.12 14.12l1.414 1.414M14.12 14.12L15.536 15.536M14.12 14.12l1.414 1.414" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generatePassword}
|
||||
className="px-4 py-3 bg-blue-50 hover:bg-blue-100 text-blue-600 border-l border-gray-300 text-sm font-medium transition-colors"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
User will need to change this password on first login
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 rounded-b-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-6 py-2.5 border border-gray-300 dark:border-gray-700 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-3 focus:ring-brand-500/10 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="create-user-form"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 focus:outline-none focus:ring-3 focus:ring-brand-500/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Creating...
|
||||
</div>
|
||||
) : (
|
||||
'Create User'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
web/src/components/Dashboard.tsx
Normal file
9
web/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { DashboardLayout } from "./DashboardLayout"
|
||||
|
||||
interface DashboardProps {
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function Dashboard({ onLogout }: DashboardProps) {
|
||||
return <DashboardLayout onLogout={onLogout} />
|
||||
}
|
||||
185
web/src/components/DashboardHome.tsx
Normal file
185
web/src/components/DashboardHome.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { User } from '../lib/supabase'
|
||||
|
||||
interface DashboardHomeProps {
|
||||
user: User
|
||||
}
|
||||
|
||||
export function DashboardHome({ user }: DashboardHomeProps) {
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
|
||||
case 'conductor':
|
||||
return 'bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400'
|
||||
case 'analyst':
|
||||
return 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
|
||||
case 'data recorder':
|
||||
return 'bg-theme-purple-500/10 text-theme-purple-500'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80'
|
||||
}
|
||||
}
|
||||
|
||||
const getPermissionsByRole = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return ['Full system access', 'User management', 'All modules', 'System configuration']
|
||||
case 'conductor':
|
||||
return ['Experiment management', 'Data collection', 'Analytics access', 'Data entry']
|
||||
case 'analyst':
|
||||
return ['Data analysis', 'Report generation', 'Read-only access', 'Analytics dashboard']
|
||||
case 'data recorder':
|
||||
return ['Data entry', 'Record management', 'Basic reporting', 'Data validation']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 gap-4 md:gap-6">
|
||||
{/* Welcome Section */}
|
||||
<div className="col-span-12 mb-6">
|
||||
<h1 className="text-title-md font-bold text-gray-800 dark:text-white/90">Dashboard</h1>
|
||||
<p className="mt-2 text-gray-500 dark:text-gray-400">Welcome to the Pecan Experiments Dashboard</p>
|
||||
</div>
|
||||
|
||||
{/* User Information Card */}
|
||||
<div className="col-span-12 xl:col-span-8">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
|
||||
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
|
||||
User Information
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Your account details and role permissions.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Email</span>
|
||||
<span className="text-sm text-gray-800 dark:text-white/90">{user.email}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Roles</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
|
||||
>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Status</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
|
||||
? 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
|
||||
: 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
|
||||
}`}>
|
||||
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">User ID</span>
|
||||
<span className="text-sm text-gray-800 dark:text-white/90 font-mono">{user.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Member since</span>
|
||||
<span className="text-sm text-gray-800 dark:text-white/90">
|
||||
{new Date(user.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Permissions */}
|
||||
<div className="col-span-12 xl:col-span-4">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
|
||||
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
|
||||
Role Permissions
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Your access levels and capabilities.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{user.roles.map((role) => (
|
||||
<div key={role} className="border border-gray-200 dark:border-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center mb-3">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{getPermissionsByRole(role).map((permission, index) => (
|
||||
<li key={index} className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-success-500 mr-2">✓</span>
|
||||
{permission}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{user.roles.includes('admin') && (
|
||||
<div className="col-span-12">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
|
||||
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Administrative shortcuts and tools.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-transparent text-sm font-medium rounded-lg text-white bg-brand-500 hover:bg-brand-600 focus:outline-none focus:ring-3 focus:ring-brand-500/10 transition-colors">
|
||||
👥 Manage Users
|
||||
</button>
|
||||
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-gray-200 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/5 transition-colors">
|
||||
🧪 View Experiments
|
||||
</button>
|
||||
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-gray-200 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/5 transition-colors">
|
||||
📊 Analytics
|
||||
</button>
|
||||
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-gray-200 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/5 transition-colors">
|
||||
⚙️ Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
web/src/components/DashboardLayout.tsx
Normal file
196
web/src/components/DashboardLayout.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { TopNavbar } from './TopNavbar'
|
||||
import { DashboardHome } from './DashboardHome'
|
||||
import { UserManagement } from './UserManagement'
|
||||
import { Experiments } from './Experiments'
|
||||
import { DataEntry } from './DataEntry'
|
||||
import { VisionSystem } from './VisionSystem'
|
||||
import { VideoStreamingPage } from '../features/video-streaming'
|
||||
import { userManagement, type User } from '../lib/supabase'
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentView, setCurrentView] = useState('dashboard')
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserProfile()
|
||||
}, [])
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const currentUser = await userManagement.getCurrentUser()
|
||||
if (currentUser) {
|
||||
setUser(currentUser)
|
||||
} else {
|
||||
setError('No authenticated user found')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch user profile')
|
||||
console.error('Profile fetch error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
// Navigate to signout route which will handle the actual logout
|
||||
window.history.pushState({}, '', '/signout')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
|
||||
const toggleMobileSidebar = () => {
|
||||
setIsMobileOpen(!isMobileOpen)
|
||||
}
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
toggleSidebar()
|
||||
} else {
|
||||
toggleMobileSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
const renderCurrentView = () => {
|
||||
if (!user) return null
|
||||
|
||||
switch (currentView) {
|
||||
case 'dashboard':
|
||||
return <DashboardHome user={user} />
|
||||
case 'user-management':
|
||||
if (user.roles.includes('admin')) {
|
||||
return <UserManagement />
|
||||
} else {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">
|
||||
Access denied. You need admin privileges to access user management.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'experiments':
|
||||
return <Experiments />
|
||||
case 'analytics':
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Analytics</h1>
|
||||
<div className="bg-green-50 border border-green-200 rounded-md p-4">
|
||||
<div className="text-sm text-green-700">
|
||||
Analytics module coming soon...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'data-entry':
|
||||
return <DataEntry />
|
||||
case 'vision-system':
|
||||
return <VisionSystem />
|
||||
case 'video-library':
|
||||
return <VideoStreamingPage />
|
||||
default:
|
||||
return <DashboardHome user={user} />
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="rounded-2xl bg-error-50 border border-error-200 p-4 dark:bg-error-500/15 dark:border-error-500/20">
|
||||
<div className="text-sm text-error-700 dark:text-error-500">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-4 w-full flex justify-center py-2.5 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gray-600 hover:bg-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">No user data available</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-4 px-4 py-2.5 bg-gray-600 text-white rounded-lg hover:bg-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen xl:flex">
|
||||
<div>
|
||||
<Sidebar
|
||||
user={user}
|
||||
currentView={currentView}
|
||||
onViewChange={setCurrentView}
|
||||
isExpanded={isExpanded}
|
||||
isMobileOpen={isMobileOpen}
|
||||
isHovered={isHovered}
|
||||
setIsHovered={setIsHovered}
|
||||
/>
|
||||
{/* Backdrop for mobile */}
|
||||
{isMobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 transition-all duration-300 ease-in-out ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
|
||||
} ${isMobileOpen ? "ml-0" : ""}`}
|
||||
>
|
||||
<TopNavbar
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
currentView={currentView}
|
||||
onToggleSidebar={handleToggleSidebar}
|
||||
isSidebarOpen={isMobileOpen}
|
||||
/>
|
||||
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">
|
||||
{renderCurrentView()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
327
web/src/components/DataEntry.tsx
Normal file
327
web/src/components/DataEntry.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { experimentManagement, repetitionManagement, userManagement, type Experiment, type ExperimentRepetition, type User } from '../lib/supabase'
|
||||
import { RepetitionDataEntryInterface } from './RepetitionDataEntryInterface'
|
||||
|
||||
export function DataEntry() {
|
||||
const [experiments, setExperiments] = useState<Experiment[]>([])
|
||||
const [experimentRepetitions, setExperimentRepetitions] = useState<Record<string, ExperimentRepetition[]>>({})
|
||||
const [selectedRepetition, setSelectedRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | null>(null)
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [experimentsData, userData] = await Promise.all([
|
||||
experimentManagement.getAllExperiments(),
|
||||
userManagement.getCurrentUser()
|
||||
])
|
||||
|
||||
setExperiments(experimentsData)
|
||||
setCurrentUser(userData)
|
||||
|
||||
// Load repetitions for each experiment
|
||||
const repetitionsMap: Record<string, ExperimentRepetition[]> = {}
|
||||
for (const experiment of experimentsData) {
|
||||
try {
|
||||
const repetitions = await repetitionManagement.getExperimentRepetitions(experiment.id)
|
||||
repetitionsMap[experiment.id] = repetitions
|
||||
} catch (err) {
|
||||
console.error(`Failed to load repetitions for experiment ${experiment.id}:`, err)
|
||||
repetitionsMap[experiment.id] = []
|
||||
}
|
||||
}
|
||||
setExperimentRepetitions(repetitionsMap)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load data')
|
||||
console.error('Load data error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRepetitionSelect = (experiment: Experiment, repetition: ExperimentRepetition) => {
|
||||
setSelectedRepetition({ experiment, repetition })
|
||||
}
|
||||
|
||||
const handleBackToList = () => {
|
||||
setSelectedRepetition(null)
|
||||
}
|
||||
|
||||
const getAllRepetitionsWithExperiments = () => {
|
||||
const allRepetitions: Array<{ experiment: Experiment; repetition: ExperimentRepetition }> = []
|
||||
|
||||
experiments.forEach(experiment => {
|
||||
const repetitions = experimentRepetitions[experiment.id] || []
|
||||
repetitions.forEach(repetition => {
|
||||
allRepetitions.push({ experiment, repetition })
|
||||
})
|
||||
})
|
||||
|
||||
return allRepetitions
|
||||
}
|
||||
|
||||
const categorizeRepetitions = () => {
|
||||
const allRepetitions = getAllRepetitionsWithExperiments()
|
||||
const now = new Date()
|
||||
|
||||
const past = allRepetitions.filter(({ repetition }) =>
|
||||
repetition.completion_status || (repetition.scheduled_date && new Date(repetition.scheduled_date) < now)
|
||||
)
|
||||
|
||||
const inProgress = allRepetitions.filter(({ repetition }) =>
|
||||
!repetition.completion_status &&
|
||||
repetition.scheduled_date &&
|
||||
new Date(repetition.scheduled_date) <= now &&
|
||||
new Date(repetition.scheduled_date) > new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
)
|
||||
|
||||
const upcoming = allRepetitions.filter(({ repetition }) =>
|
||||
!repetition.completion_status &&
|
||||
repetition.scheduled_date &&
|
||||
new Date(repetition.scheduled_date) > now
|
||||
)
|
||||
|
||||
return { past, inProgress, upcoming }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading experiments...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedRepetition) {
|
||||
return (
|
||||
<RepetitionDataEntryInterface
|
||||
experiment={selectedRepetition.experiment}
|
||||
repetition={selectedRepetition.repetition}
|
||||
currentUser={currentUser!}
|
||||
onBack={handleBackToList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Data Entry</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Select a repetition to enter measurement data
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Repetitions organized by status - flat list */}
|
||||
{(() => {
|
||||
const { past: pastRepetitions, inProgress: inProgressRepetitions, upcoming: upcomingRepetitions } = categorizeRepetitions()
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Past/Completed Repetitions */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
<span className="w-4 h-4 bg-green-500 rounded-full mr-3"></span>
|
||||
Past/Completed ({pastRepetitions.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Completed or past scheduled repetitions
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{pastRepetitions.map(({ experiment, repetition }) => (
|
||||
<RepetitionCard
|
||||
key={repetition.id}
|
||||
experiment={experiment}
|
||||
repetition={repetition}
|
||||
onSelect={handleRepetitionSelect}
|
||||
status="past"
|
||||
/>
|
||||
))}
|
||||
{pastRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
No completed repetitions
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In Progress Repetitions */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
<span className="w-4 h-4 bg-blue-500 rounded-full mr-3"></span>
|
||||
In Progress ({inProgressRepetitions.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Currently scheduled or active repetitions
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{inProgressRepetitions.map(({ experiment, repetition }) => (
|
||||
<RepetitionCard
|
||||
key={repetition.id}
|
||||
experiment={experiment}
|
||||
repetition={repetition}
|
||||
onSelect={handleRepetitionSelect}
|
||||
status="in-progress"
|
||||
/>
|
||||
))}
|
||||
{inProgressRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
No repetitions in progress
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Repetitions */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
<span className="w-4 h-4 bg-yellow-500 rounded-full mr-3"></span>
|
||||
Upcoming ({upcomingRepetitions.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Future scheduled repetitions
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{upcomingRepetitions.map(({ experiment, repetition }) => (
|
||||
<RepetitionCard
|
||||
key={repetition.id}
|
||||
experiment={experiment}
|
||||
repetition={repetition}
|
||||
onSelect={handleRepetitionSelect}
|
||||
status="upcoming"
|
||||
/>
|
||||
))}
|
||||
{upcomingRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
No upcoming repetitions
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{experiments.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500">
|
||||
No experiments available for data entry
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// RepetitionCard component for displaying individual repetitions
|
||||
interface RepetitionCardProps {
|
||||
experiment: Experiment
|
||||
repetition: ExperimentRepetition
|
||||
onSelect: (experiment: Experiment, repetition: ExperimentRepetition) => void
|
||||
status: 'past' | 'in-progress' | 'upcoming'
|
||||
}
|
||||
|
||||
function RepetitionCard({ experiment, repetition, onSelect, status }: RepetitionCardProps) {
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'past':
|
||||
return 'border-green-200 bg-green-50 hover:bg-green-100'
|
||||
case 'in-progress':
|
||||
return 'border-blue-200 bg-blue-50 hover:bg-blue-100'
|
||||
case 'upcoming':
|
||||
return 'border-yellow-200 bg-yellow-50 hover:bg-yellow-100'
|
||||
default:
|
||||
return 'border-gray-200 bg-gray-50 hover:bg-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'past':
|
||||
return '✓'
|
||||
case 'in-progress':
|
||||
return '▶'
|
||||
case 'upcoming':
|
||||
return '⏰'
|
||||
default:
|
||||
return '○'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(experiment, repetition)}
|
||||
className={`w-full text-left p-4 border-2 rounded-lg hover:shadow-lg transition-all duration-200 ${getStatusColor()}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Large, bold experiment number */}
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
#{experiment.experiment_number}
|
||||
</span>
|
||||
{/* Smaller repetition number */}
|
||||
<span className="text-lg font-semibold text-gray-700">
|
||||
Rep #{repetition.repetition_number}
|
||||
</span>
|
||||
<span className="text-lg">{getStatusIcon()}</span>
|
||||
</div>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${repetition.schedule_status === 'scheduled'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Experiment details */}
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
{experiment.soaking_duration_hr}h soaking • {experiment.air_drying_time_min}min drying
|
||||
</div>
|
||||
|
||||
{repetition.scheduled_date && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<strong>Scheduled:</strong> {new Date(repetition.scheduled_date).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
Click to enter data for this repetition
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
229
web/src/components/DataEntryInterface.tsx
Normal file
229
web/src/components/DataEntryInterface.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { type Experiment, type User, type ExperimentPhase } from '../lib/supabase'
|
||||
import { DraftManager } from './DraftManager'
|
||||
import { PhaseSelector } from './PhaseSelector'
|
||||
|
||||
// DEPRECATED: This component is deprecated in favor of RepetitionDataEntryInterface
|
||||
// which uses the new phase-specific draft system
|
||||
|
||||
interface DataEntryInterfaceProps {
|
||||
experiment: Experiment
|
||||
currentUser: User
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
// Temporary type for backward compatibility
|
||||
interface LegacyDataEntry {
|
||||
id: string
|
||||
experiment_id: string
|
||||
user_id: string
|
||||
status: 'draft' | 'submitted'
|
||||
entry_name?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at?: string | null
|
||||
}
|
||||
|
||||
export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntryInterfaceProps) {
|
||||
const [userDataEntries, setUserDataEntries] = useState<LegacyDataEntry[]>([])
|
||||
const [selectedDataEntry, setSelectedDataEntry] = useState<LegacyDataEntry | null>(null)
|
||||
const [selectedPhase, setSelectedPhase] = useState<ExperimentPhase | null>(null)
|
||||
const [showDraftManager, setShowDraftManager] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadUserDataEntries()
|
||||
}, [experiment.id, currentUser.id])
|
||||
|
||||
const loadUserDataEntries = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// DEPRECATED: Using empty array since this component is deprecated
|
||||
const entries: LegacyDataEntry[] = []
|
||||
setUserDataEntries(entries)
|
||||
|
||||
// Auto-select the most recent draft or create a new one
|
||||
const drafts = entries.filter(entry => entry.status === 'draft')
|
||||
if (drafts.length > 0) {
|
||||
setSelectedDataEntry(drafts[0])
|
||||
} else {
|
||||
// Create a new draft entry
|
||||
await handleCreateNewDraft()
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load data entries')
|
||||
console.error('Load data entries error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateNewDraft = async () => {
|
||||
setError('This component is deprecated. Please use the new repetition-based data entry system.')
|
||||
}
|
||||
|
||||
const handleSelectDataEntry = (entry: LegacyDataEntry) => {
|
||||
setSelectedDataEntry(entry)
|
||||
setShowDraftManager(false)
|
||||
setSelectedPhase(null)
|
||||
}
|
||||
|
||||
const handleDeleteDraft = async (_entryId: string) => {
|
||||
setError('This component is deprecated. Please use the new repetition-based data entry system.')
|
||||
}
|
||||
|
||||
const handleSubmitEntry = async (_entryId: string) => {
|
||||
setError('This component is deprecated. Please use the new repetition-based data entry system.')
|
||||
}
|
||||
|
||||
const handlePhaseSelect = (phase: ExperimentPhase) => {
|
||||
setSelectedPhase(phase)
|
||||
}
|
||||
|
||||
const handleBackToPhases = () => {
|
||||
setSelectedPhase(null)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading data entries...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
|
||||
>
|
||||
Back to Experiments
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-gray-600 hover:text-gray-900 mb-2"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Experiments
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Experiment #{experiment.experiment_number}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<button
|
||||
onClick={() => setShowDraftManager(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 mr-2"
|
||||
>
|
||||
Manage Drafts
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateNewDraft}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
|
||||
>
|
||||
New Draft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Experiment Details */}
|
||||
<div className="mt-4 bg-gray-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Repetitions:</span>
|
||||
<span className="ml-1 text-gray-900">{experiment.reps_required}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Soaking Duration:</span>
|
||||
<span className="ml-1 text-gray-900">{experiment.soaking_duration_hr}h</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Air Drying:</span>
|
||||
<span className="ml-1 text-gray-900">{experiment.air_drying_time_min}min</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Status:</span>
|
||||
<span className={`ml-1 ${experiment.completion_status ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||
{experiment.completion_status ? 'Completed' : 'In Progress'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Scheduled date removed - this is now handled at repetition level */}
|
||||
</div>
|
||||
|
||||
{/* Current Draft Info */}
|
||||
{selectedDataEntry && (
|
||||
<div className="mt-4 bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium text-blue-700">Current Draft:</span>
|
||||
<span className="ml-2 text-blue-900">{selectedDataEntry.entry_name}</span>
|
||||
<span className="ml-2 text-sm text-blue-600">
|
||||
Created: {new Date(selectedDataEntry.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSubmitEntry(selectedDataEntry.id)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
|
||||
>
|
||||
Submit Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
{showDraftManager ? (
|
||||
<DraftManager
|
||||
userDataEntries={userDataEntries}
|
||||
selectedDataEntry={selectedDataEntry}
|
||||
onSelectEntry={handleSelectDataEntry}
|
||||
onDeleteDraft={handleDeleteDraft}
|
||||
onCreateNew={handleCreateNewDraft}
|
||||
onClose={() => setShowDraftManager(false)}
|
||||
/>
|
||||
) : selectedPhase ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">
|
||||
This component is deprecated. Please use the new repetition-based data entry system.
|
||||
</div>
|
||||
</div>
|
||||
) : selectedDataEntry ? (
|
||||
<PhaseSelector
|
||||
dataEntry={selectedDataEntry}
|
||||
onPhaseSelect={handlePhaseSelect}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-gray-500">
|
||||
No data entry selected. Please create a new draft or select an existing one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
188
web/src/components/DraftManager.tsx
Normal file
188
web/src/components/DraftManager.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
// DEPRECATED: This component is deprecated in favor of PhaseDraftManager
|
||||
|
||||
// Temporary type for backward compatibility
|
||||
interface LegacyDataEntry {
|
||||
id: string
|
||||
experiment_id: string
|
||||
user_id: string
|
||||
status: 'draft' | 'submitted'
|
||||
entry_name?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at?: string | null
|
||||
}
|
||||
|
||||
interface DraftManagerProps {
|
||||
userDataEntries: LegacyDataEntry[]
|
||||
selectedDataEntry: LegacyDataEntry | null
|
||||
onSelectEntry: (entry: LegacyDataEntry) => void
|
||||
onDeleteDraft: (entryId: string) => void
|
||||
onCreateNew: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DraftManager({
|
||||
userDataEntries,
|
||||
selectedDataEntry,
|
||||
onSelectEntry,
|
||||
onDeleteDraft,
|
||||
onCreateNew,
|
||||
onClose
|
||||
}: DraftManagerProps) {
|
||||
const drafts = userDataEntries.filter(entry => entry.status === 'draft')
|
||||
const submitted = userDataEntries.filter(entry => entry.status === 'submitted')
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium text-gray-900">Draft Manager</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Draft Entries */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-md font-medium text-gray-900">
|
||||
Draft Entries ({drafts.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={onCreateNew}
|
||||
className="px-3 py-1 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
|
||||
>
|
||||
Create New Draft
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>No draft entries found</p>
|
||||
<p className="text-sm mt-1">Create a new draft to start entering data</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{drafts.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`border rounded-lg p-4 ${selectedDataEntry?.id === entry.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
{entry.entry_name || 'Untitled Draft'}
|
||||
</h4>
|
||||
{selectedDataEntry?.id === entry.id && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div>Created: {new Date(entry.created_at).toLocaleString()}</div>
|
||||
<div>Last updated: {new Date(entry.updated_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onSelectEntry(entry)}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
{selectedDataEntry?.id === entry.id ? 'Continue' : 'Select'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteDraft(entry.id)}
|
||||
className="px-3 py-1 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submitted Entries */}
|
||||
<div>
|
||||
<h3 className="text-md font-medium text-gray-900 mb-4">
|
||||
Submitted Entries ({submitted.length})
|
||||
</h3>
|
||||
|
||||
{submitted.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>No submitted entries found</p>
|
||||
<p className="text-sm mt-1">Submit a draft to see it here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{submitted.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="border border-green-200 bg-green-50 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
{entry.entry_name || 'Untitled Entry'}
|
||||
</h4>
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Submitted
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div>Created: {new Date(entry.created_at).toLocaleString()}</div>
|
||||
{entry.submitted_at && (
|
||||
<div>Submitted: {new Date(entry.submitted_at).toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onSelectEntry(entry)}
|
||||
className="px-3 py-1 bg-gray-600 text-white text-sm rounded-md hover:bg-gray-700"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
370
web/src/components/ExperimentForm.tsx
Normal file
370
web/src/components/ExperimentForm.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import { useState } from 'react'
|
||||
import type { CreateExperimentRequest, UpdateExperimentRequest, ScheduleStatus, ResultsStatus } from '../lib/supabase'
|
||||
|
||||
interface ExperimentFormProps {
|
||||
initialData?: Partial<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>
|
||||
onSubmit: (data: CreateExperimentRequest | UpdateExperimentRequest) => Promise<void>
|
||||
onCancel: () => void
|
||||
isEditing?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = false, loading = false }: ExperimentFormProps) {
|
||||
const [formData, setFormData] = useState<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>({
|
||||
experiment_number: initialData?.experiment_number || 0,
|
||||
reps_required: initialData?.reps_required || 1,
|
||||
soaking_duration_hr: initialData?.soaking_duration_hr || 0,
|
||||
air_drying_time_min: initialData?.air_drying_time_min || 0,
|
||||
plate_contact_frequency_hz: initialData?.plate_contact_frequency_hz || 1,
|
||||
throughput_rate_pecans_sec: initialData?.throughput_rate_pecans_sec || 1,
|
||||
crush_amount_in: initialData?.crush_amount_in || 0,
|
||||
entry_exit_height_diff_in: initialData?.entry_exit_height_diff_in || 0,
|
||||
schedule_status: initialData?.schedule_status || 'pending schedule',
|
||||
results_status: initialData?.results_status || 'valid',
|
||||
completion_status: initialData?.completion_status || false
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
// Required field validation
|
||||
if (!formData.experiment_number || formData.experiment_number <= 0) {
|
||||
newErrors.experiment_number = 'Experiment number must be a positive integer'
|
||||
}
|
||||
|
||||
if (!formData.reps_required || formData.reps_required <= 0) {
|
||||
newErrors.reps_required = 'Repetitions required must be a positive integer'
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (formData.soaking_duration_hr < 0) {
|
||||
newErrors.soaking_duration_hr = 'Soaking duration cannot be negative'
|
||||
}
|
||||
|
||||
if (formData.air_drying_time_min < 0) {
|
||||
newErrors.air_drying_time_min = 'Air drying time cannot be negative'
|
||||
}
|
||||
|
||||
if (!formData.plate_contact_frequency_hz || formData.plate_contact_frequency_hz <= 0) {
|
||||
newErrors.plate_contact_frequency_hz = 'Plate contact frequency must be positive'
|
||||
}
|
||||
|
||||
if (!formData.throughput_rate_pecans_sec || formData.throughput_rate_pecans_sec <= 0) {
|
||||
newErrors.throughput_rate_pecans_sec = 'Throughput rate must be positive'
|
||||
}
|
||||
|
||||
if (formData.crush_amount_in < 0) {
|
||||
newErrors.crush_amount_in = 'Crush amount cannot be negative'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare data for submission
|
||||
const submitData = isEditing ? formData : {
|
||||
experiment_number: formData.experiment_number,
|
||||
reps_required: formData.reps_required,
|
||||
soaking_duration_hr: formData.soaking_duration_hr,
|
||||
air_drying_time_min: formData.air_drying_time_min,
|
||||
plate_contact_frequency_hz: formData.plate_contact_frequency_hz,
|
||||
throughput_rate_pecans_sec: formData.throughput_rate_pecans_sec,
|
||||
crush_amount_in: formData.crush_amount_in,
|
||||
entry_exit_height_diff_in: formData.entry_exit_height_diff_in,
|
||||
schedule_status: formData.schedule_status,
|
||||
results_status: formData.results_status
|
||||
}
|
||||
|
||||
await onSubmit(submitData)
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof typeof formData, value: string | number | boolean) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
|
||||
// Clear error for this field when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[field]: ''
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="experiment_number" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Experiment Number *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="experiment_number"
|
||||
value={formData.experiment_number}
|
||||
onChange={(e) => handleInputChange('experiment_number', parseInt(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.experiment_number ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter unique experiment number"
|
||||
min="1"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
{errors.experiment_number && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.experiment_number}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="reps_required" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Repetitions Required *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="reps_required"
|
||||
value={formData.reps_required}
|
||||
onChange={(e) => handleInputChange('reps_required', parseInt(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.reps_required ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Total repetitions needed"
|
||||
min="1"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
{errors.reps_required && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.reps_required}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Experiment Parameters */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Experiment Parameters</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="soaking_duration_hr" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Soaking Duration (hours) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="soaking_duration_hr"
|
||||
value={formData.soaking_duration_hr}
|
||||
onChange={(e) => handleInputChange('soaking_duration_hr', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.soaking_duration_hr ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.0"
|
||||
min="0"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.soaking_duration_hr && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.soaking_duration_hr}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="air_drying_time_min" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Air Drying Time (minutes) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="air_drying_time_min"
|
||||
value={formData.air_drying_time_min}
|
||||
onChange={(e) => handleInputChange('air_drying_time_min', parseInt(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.air_drying_time_min ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
{errors.air_drying_time_min && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.air_drying_time_min}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="plate_contact_frequency_hz" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Plate Contact Frequency (Hz) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="plate_contact_frequency_hz"
|
||||
value={formData.plate_contact_frequency_hz}
|
||||
onChange={(e) => handleInputChange('plate_contact_frequency_hz', parseFloat(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.plate_contact_frequency_hz ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="1.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.plate_contact_frequency_hz && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.plate_contact_frequency_hz}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="throughput_rate_pecans_sec" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Throughput Rate (pecans/sec) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="throughput_rate_pecans_sec"
|
||||
value={formData.throughput_rate_pecans_sec}
|
||||
onChange={(e) => handleInputChange('throughput_rate_pecans_sec', parseFloat(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.throughput_rate_pecans_sec ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="1.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.throughput_rate_pecans_sec && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.throughput_rate_pecans_sec}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="crush_amount_in" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Crush Amount (thousandths of inch) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="crush_amount_in"
|
||||
value={formData.crush_amount_in}
|
||||
onChange={(e) => handleInputChange('crush_amount_in', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.crush_amount_in ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.0"
|
||||
min="0"
|
||||
step="0.001"
|
||||
required
|
||||
/>
|
||||
{errors.crush_amount_in && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.crush_amount_in}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="entry_exit_height_diff_in" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Entry/Exit Height Difference (inches) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="entry_exit_height_diff_in"
|
||||
value={formData.entry_exit_height_diff_in}
|
||||
onChange={(e) => handleInputChange('entry_exit_height_diff_in', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-sm px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.entry_exit_height_diff_in ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.0 (can be negative)"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.entry_exit_height_diff_in && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.entry_exit_height_diff_in}</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-gray-500">Positive values indicate entry is higher than exit</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Fields (only show when editing) */}
|
||||
{isEditing && (
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Status</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="schedule_status" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Schedule Status
|
||||
</label>
|
||||
<select
|
||||
id="schedule_status"
|
||||
value={formData.schedule_status}
|
||||
onChange={(e) => handleInputChange('schedule_status', e.target.value as ScheduleStatus)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||
>
|
||||
<option value="pending schedule">Pending Schedule</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="canceled">Canceled</option>
|
||||
<option value="aborted">Aborted</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="results_status" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Results Status
|
||||
</label>
|
||||
<select
|
||||
id="results_status"
|
||||
value={formData.results_status}
|
||||
onChange={(e) => handleInputChange('results_status', e.target.value as ResultsStatus)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||
>
|
||||
<option value="valid">Valid</option>
|
||||
<option value="invalid">Invalid</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="completion_status" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Completion Status
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="completion_status"
|
||||
checked={formData.completion_status}
|
||||
onChange={(e) => handleInputChange('completion_status', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="completion_status" className="ml-2 text-sm text-gray-700">
|
||||
Mark as completed
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end space-x-4 pt-6 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-6 py-3 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-3 border border-transparent rounded-lg text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? (isEditing ? 'Updating...' : 'Creating...') : (isEditing ? 'Update Experiment' : 'Create Experiment')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
127
web/src/components/ExperimentModal.tsx
Normal file
127
web/src/components/ExperimentModal.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { ExperimentForm } from './ExperimentForm'
|
||||
import { experimentManagement } from '../lib/supabase'
|
||||
import type { Experiment, CreateExperimentRequest, UpdateExperimentRequest } from '../lib/supabase'
|
||||
|
||||
interface ExperimentModalProps {
|
||||
experiment?: Experiment
|
||||
onClose: () => void
|
||||
onExperimentSaved: (experiment: Experiment) => void
|
||||
}
|
||||
|
||||
export function ExperimentModal({ experiment, onClose, onExperimentSaved }: ExperimentModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isEditing = !!experiment
|
||||
|
||||
const handleSubmit = async (data: CreateExperimentRequest | UpdateExperimentRequest) => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
let savedExperiment: Experiment
|
||||
|
||||
if (isEditing && experiment) {
|
||||
// Check if experiment number is unique (excluding current experiment)
|
||||
if ('experiment_number' in data && data.experiment_number !== undefined && data.experiment_number !== experiment.experiment_number) {
|
||||
const isUnique = await experimentManagement.isExperimentNumberUnique(data.experiment_number, experiment.id)
|
||||
if (!isUnique) {
|
||||
setError('Experiment number already exists. Please choose a different number.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
savedExperiment = await experimentManagement.updateExperiment(experiment.id, data)
|
||||
} else {
|
||||
// Check if experiment number is unique for new experiments
|
||||
const createData = data as CreateExperimentRequest
|
||||
const isUnique = await experimentManagement.isExperimentNumberUnique(createData.experiment_number)
|
||||
if (!isUnique) {
|
||||
setError('Experiment number already exists. Please choose a different number.')
|
||||
return
|
||||
}
|
||||
|
||||
savedExperiment = await experimentManagement.createExperiment(createData)
|
||||
}
|
||||
|
||||
onExperimentSaved(savedExperiment)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || `Failed to ${isEditing ? 'update' : 'create'} experiment`)
|
||||
console.error('Experiment save error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-4xl mx-auto max-h-[90vh] overflow-y-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800 rounded-t-2xl">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">
|
||||
{isEditing ? `Edit Experiment #${experiment.experiment_number}` : 'Create New Experiment'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
||||
<div className="mt-2 text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<ExperimentForm
|
||||
initialData={experiment}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
isEditing={isEditing}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
445
web/src/components/Experiments.tsx
Normal file
445
web/src/components/Experiments.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ExperimentModal } from './ExperimentModal'
|
||||
import { RepetitionScheduleModal } from './RepetitionScheduleModal'
|
||||
import { experimentManagement, repetitionManagement, userManagement } from '../lib/supabase'
|
||||
import type { Experiment, ExperimentRepetition, User, ScheduleStatus, ResultsStatus } from '../lib/supabase'
|
||||
|
||||
export function Experiments() {
|
||||
const [experiments, setExperiments] = useState<Experiment[]>([])
|
||||
const [experimentRepetitions, setExperimentRepetitions] = useState<Record<string, ExperimentRepetition[]>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingExperiment, setEditingExperiment] = useState<Experiment | undefined>(undefined)
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null)
|
||||
|
||||
const [showRepetitionScheduleModal, setShowRepetitionScheduleModal] = useState(false)
|
||||
const [schedulingRepetition, setSchedulingRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [experimentsData, userData] = await Promise.all([
|
||||
experimentManagement.getAllExperiments(),
|
||||
userManagement.getCurrentUser()
|
||||
])
|
||||
|
||||
setExperiments(experimentsData)
|
||||
setCurrentUser(userData)
|
||||
|
||||
// Load repetitions for each experiment
|
||||
const repetitionsMap: Record<string, ExperimentRepetition[]> = {}
|
||||
for (const experiment of experimentsData) {
|
||||
try {
|
||||
const repetitions = await repetitionManagement.getExperimentRepetitions(experiment.id)
|
||||
repetitionsMap[experiment.id] = repetitions
|
||||
} catch (err) {
|
||||
console.error(`Failed to load repetitions for experiment ${experiment.id}:`, err)
|
||||
repetitionsMap[experiment.id] = []
|
||||
}
|
||||
}
|
||||
setExperimentRepetitions(repetitionsMap)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load experiments')
|
||||
console.error('Load experiments error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canManageExperiments = currentUser?.roles.includes('admin') || currentUser?.roles.includes('conductor')
|
||||
|
||||
const handleCreateExperiment = () => {
|
||||
setEditingExperiment(undefined)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleEditExperiment = (experiment: Experiment) => {
|
||||
setEditingExperiment(experiment)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleExperimentSaved = async (experiment: Experiment) => {
|
||||
if (editingExperiment) {
|
||||
// Update existing experiment
|
||||
setExperiments(prev => prev.map(exp => exp.id === experiment.id ? experiment : exp))
|
||||
} else {
|
||||
// Add new experiment and create all its repetitions
|
||||
setExperiments(prev => [experiment, ...prev])
|
||||
|
||||
try {
|
||||
// Create all repetitions for the new experiment
|
||||
const repetitions = await repetitionManagement.createAllRepetitions(experiment.id)
|
||||
setExperimentRepetitions(prev => ({
|
||||
...prev,
|
||||
[experiment.id]: repetitions
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Failed to create repetitions:', err)
|
||||
}
|
||||
}
|
||||
setShowModal(false)
|
||||
setEditingExperiment(undefined)
|
||||
}
|
||||
|
||||
const handleScheduleRepetition = (experiment: Experiment, repetition: ExperimentRepetition) => {
|
||||
setSchedulingRepetition({ experiment, repetition })
|
||||
setShowRepetitionScheduleModal(true)
|
||||
}
|
||||
|
||||
const handleRepetitionScheduleUpdated = (updatedRepetition: ExperimentRepetition) => {
|
||||
setExperimentRepetitions(prev => ({
|
||||
...prev,
|
||||
[updatedRepetition.experiment_id]: prev[updatedRepetition.experiment_id]?.map(rep =>
|
||||
rep.id === updatedRepetition.id ? updatedRepetition : rep
|
||||
) || []
|
||||
}))
|
||||
setShowRepetitionScheduleModal(false)
|
||||
setSchedulingRepetition(undefined)
|
||||
}
|
||||
|
||||
const handleCreateRepetition = async (experiment: Experiment, repetitionNumber: number) => {
|
||||
try {
|
||||
const newRepetition = await repetitionManagement.createRepetition({
|
||||
experiment_id: experiment.id,
|
||||
repetition_number: repetitionNumber,
|
||||
schedule_status: 'pending schedule'
|
||||
})
|
||||
|
||||
setExperimentRepetitions(prev => ({
|
||||
...prev,
|
||||
[experiment.id]: [...(prev[experiment.id] || []), newRepetition].sort((a, b) => a.repetition_number - b.repetition_number)
|
||||
}))
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create repetition')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteExperiment = async (experiment: Experiment) => {
|
||||
if (!currentUser?.roles.includes('admin')) {
|
||||
alert('Only administrators can delete experiments.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete Experiment #${experiment.experiment_number}? This action cannot be undone.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await experimentManagement.deleteExperiment(experiment.id)
|
||||
setExperiments(prev => prev.filter(exp => exp.id !== experiment.id))
|
||||
} catch (err: any) {
|
||||
alert(`Failed to delete experiment: ${err.message}`)
|
||||
console.error('Delete experiment error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const getRepetitionStatusSummary = (repetitions: ExperimentRepetition[]) => {
|
||||
const scheduled = repetitions.filter(r => r.schedule_status === 'scheduled').length
|
||||
const pending = repetitions.filter(r => r.schedule_status === 'pending schedule').length
|
||||
const completed = repetitions.filter(r => r.completion_status).length
|
||||
|
||||
return { scheduled, pending, completed, total: repetitions.length }
|
||||
}
|
||||
|
||||
const getStatusBadgeColor = (status: ScheduleStatus | ResultsStatus) => {
|
||||
switch (status) {
|
||||
case 'pending schedule':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'scheduled':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'canceled':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'aborted':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'valid':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'invalid':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
// Remove filtering for now since experiments don't have schedule_status anymore
|
||||
const filteredExperiments = experiments
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Experiments</h1>
|
||||
<p className="mt-2 text-gray-600">Manage pecan processing experiment definitions</p>
|
||||
<p className="mt-2 text-gray-600">This is where you define the blueprint of an experiment with the required configurations and parameters, as well as the number of repetitions needed for that experiment.</p>
|
||||
</div>
|
||||
{canManageExperiments && (
|
||||
<button
|
||||
onClick={handleCreateExperiment}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ New Experiment
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Experiments Table */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Experiments ({filteredExperiments.length})
|
||||
</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
{canManageExperiments ? 'Click on any experiment to edit details' : 'View experiment definitions and status'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Experiment #
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Reps Required
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Experiment Parameters
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Repetitions Status
|
||||
</th>
|
||||
{canManageExperiments && (
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Manage Repetitions
|
||||
</th>
|
||||
)}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Results Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Completion
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
{canManageExperiments && (
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredExperiments.map((experiment) => (
|
||||
<tr
|
||||
key={experiment.id}
|
||||
className={canManageExperiments ? "hover:bg-gray-50 cursor-pointer" : ""}
|
||||
onClick={canManageExperiments ? () => handleEditExperiment(experiment) : undefined}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
#{experiment.experiment_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{experiment.reps_required}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
<div className="space-y-1">
|
||||
<div>Soaking: {experiment.soaking_duration_hr}h</div>
|
||||
<div>Drying: {experiment.air_drying_time_min}min</div>
|
||||
<div>Frequency: {experiment.plate_contact_frequency_hz}Hz</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{(() => {
|
||||
const repetitions = experimentRepetitions[experiment.id] || []
|
||||
const summary = getRepetitionStatusSummary(repetitions)
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-gray-600">
|
||||
{summary.total} total • {summary.scheduled} scheduled • {summary.pending} pending
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
{summary.scheduled > 0 && (
|
||||
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
{summary.scheduled} scheduled
|
||||
</span>
|
||||
)}
|
||||
{summary.pending > 0 && (
|
||||
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
{summary.pending} pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
{canManageExperiments && (
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
const repetitions = experimentRepetitions[experiment.id] || []
|
||||
return repetitions.map((repetition) => (
|
||||
<div key={repetition.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium">Rep #{repetition.repetition_number}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(repetition.schedule_status)}`}>
|
||||
{repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleScheduleRepetition(experiment, repetition)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
|
||||
title={repetition.schedule_status === 'scheduled' ? 'Reschedule' : 'Schedule'}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
{(() => {
|
||||
const repetitions = experimentRepetitions[experiment.id] || []
|
||||
const missingReps = experiment.reps_required - repetitions.length
|
||||
if (missingReps > 0) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCreateRepetition(experiment, repetitions.length + 1)
|
||||
}}
|
||||
className="w-full text-sm text-blue-600 hover:text-blue-900 py-1 px-2 border border-blue-300 rounded hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
+ Add Rep #{repetitions.length + 1}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(experiment.results_status)}`}>
|
||||
{experiment.results_status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${experiment.completion_status
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{experiment.completion_status ? 'Completed' : 'In Progress'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(experiment.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
{canManageExperiments && (
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditExperiment(experiment)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{currentUser?.roles.includes('admin') && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteExperiment(experiment)
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredExperiments.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiments found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Get started by creating your first experiment.
|
||||
</p>
|
||||
{canManageExperiments && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={handleCreateExperiment}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ Create First Experiment
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Experiment Modal */}
|
||||
{showModal && (
|
||||
<ExperimentModal
|
||||
experiment={editingExperiment}
|
||||
onClose={() => setShowModal(false)}
|
||||
onExperimentSaved={handleExperimentSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Repetition Schedule Modal */}
|
||||
{showRepetitionScheduleModal && schedulingRepetition && (
|
||||
<RepetitionScheduleModal
|
||||
experiment={schedulingRepetition.experiment}
|
||||
repetition={schedulingRepetition.repetition}
|
||||
onClose={() => setShowRepetitionScheduleModal(false)}
|
||||
onScheduleUpdated={handleRepetitionScheduleUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
web/src/components/Login.tsx
Normal file
104
web/src/components/Login.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: () => void
|
||||
}
|
||||
|
||||
export function Login({ onLoginSuccess }: LoginProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
} else if (data.user) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred')
|
||||
console.error('Login error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
RBAC Authentication System
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
828
web/src/components/PhaseDataEntry.tsx
Normal file
828
web/src/components/PhaseDataEntry.tsx
Normal file
@@ -0,0 +1,828 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { phaseDraftManagement, type Experiment, type ExperimentPhaseDraft, type ExperimentPhase, type ExperimentPhaseData, type ExperimentRepetition, type User } from '../lib/supabase'
|
||||
import { PhaseDraftManager } from './PhaseDraftManager'
|
||||
|
||||
interface PhaseDataEntryProps {
|
||||
experiment: Experiment
|
||||
repetition: ExperimentRepetition
|
||||
phase: ExperimentPhase
|
||||
currentUser: User
|
||||
onBack: () => void
|
||||
onDataSaved: () => void
|
||||
}
|
||||
|
||||
export function PhaseDataEntry({ experiment, repetition, phase, currentUser, onBack, onDataSaved }: PhaseDataEntryProps) {
|
||||
const [selectedDraft, setSelectedDraft] = useState<ExperimentPhaseDraft | null>(null)
|
||||
const [phaseData, setPhaseData] = useState<Partial<ExperimentPhaseData>>({})
|
||||
const [diameterMeasurements, setDiameterMeasurements] = useState<number[]>(Array(10).fill(0))
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
||||
const [showDraftManager, setShowDraftManager] = useState(false)
|
||||
|
||||
// Auto-save interval (30 seconds)
|
||||
const AUTO_SAVE_INTERVAL = 30000
|
||||
|
||||
useEffect(() => {
|
||||
loadUserDrafts()
|
||||
}, [repetition.id, phase])
|
||||
|
||||
const loadUserDrafts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const drafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase)
|
||||
|
||||
// Auto-select the most recent draft or show draft manager if none exist
|
||||
if (drafts.length > 0) {
|
||||
const mostRecentDraft = drafts[0] // Already sorted by created_at desc
|
||||
setSelectedDraft(mostRecentDraft)
|
||||
await loadPhaseDataForDraft(mostRecentDraft)
|
||||
} else {
|
||||
setShowDraftManager(true)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load drafts'
|
||||
setError(errorMessage)
|
||||
console.error('Load drafts error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadPhaseDataForDraft = async (draft: ExperimentPhaseDraft) => {
|
||||
try {
|
||||
setError(null)
|
||||
|
||||
const existingData = await phaseDraftManagement.getPhaseDataForDraft(draft.id)
|
||||
|
||||
if (existingData) {
|
||||
setPhaseData(existingData)
|
||||
|
||||
// Load diameter measurements if they exist
|
||||
if (existingData.diameter_measurements) {
|
||||
const measurements = Array(10).fill(0)
|
||||
existingData.diameter_measurements.forEach(measurement => {
|
||||
if (measurement.measurement_number >= 1 && measurement.measurement_number <= 10) {
|
||||
measurements[measurement.measurement_number - 1] = measurement.diameter_in
|
||||
}
|
||||
})
|
||||
setDiameterMeasurements(measurements)
|
||||
}
|
||||
} else {
|
||||
// Initialize empty phase data
|
||||
setPhaseData({
|
||||
phase_draft_id: draft.id,
|
||||
phase_name: phase
|
||||
})
|
||||
setDiameterMeasurements(Array(10).fill(0))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load phase data'
|
||||
setError(errorMessage)
|
||||
console.error('Load phase data error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const autoSave = useCallback(async () => {
|
||||
if (!selectedDraft || selectedDraft.status === 'submitted') return // Don't auto-save submitted drafts
|
||||
|
||||
try {
|
||||
await phaseDraftManagement.autoSaveDraft(selectedDraft.id, phaseData)
|
||||
|
||||
// Save diameter measurements if this is air-drying phase and we have measurements
|
||||
if (phase === 'air-drying' && phaseData.id && diameterMeasurements.some(m => m > 0)) {
|
||||
const validMeasurements = diameterMeasurements.filter(m => m > 0)
|
||||
if (validMeasurements.length > 0) {
|
||||
await phaseDraftManagement.saveDiameterMeasurements(phaseData.id, diameterMeasurements)
|
||||
|
||||
// Update average diameter
|
||||
const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements)
|
||||
setPhaseData(prev => ({ ...prev, avg_pecan_diameter_in: avgDiameter }))
|
||||
}
|
||||
}
|
||||
|
||||
setLastSaved(new Date())
|
||||
} catch (error) {
|
||||
console.warn('Auto-save failed:', error)
|
||||
}
|
||||
}, [selectedDraft, phase, phaseData, diameterMeasurements])
|
||||
|
||||
// Auto-save effect
|
||||
useEffect(() => {
|
||||
if (!loading && selectedDraft && phaseData.phase_draft_id) {
|
||||
const interval = setInterval(() => {
|
||||
autoSave()
|
||||
}, AUTO_SAVE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [phaseData, diameterMeasurements, loading, autoSave, selectedDraft])
|
||||
|
||||
const handleInputChange = (field: string, value: unknown) => {
|
||||
setPhaseData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleDiameterChange = (index: number, value: number) => {
|
||||
const newMeasurements = [...diameterMeasurements]
|
||||
newMeasurements[index] = value
|
||||
setDiameterMeasurements(newMeasurements)
|
||||
|
||||
// Calculate and update average
|
||||
const validMeasurements = newMeasurements.filter(m => m > 0)
|
||||
if (validMeasurements.length > 0) {
|
||||
const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements)
|
||||
handleInputChange('avg_pecan_diameter_in', avgDiameter)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedDraft) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
// Save phase data
|
||||
const savedData = await phaseDraftManagement.upsertPhaseData(selectedDraft.id, phaseData)
|
||||
setPhaseData(savedData)
|
||||
|
||||
// Save diameter measurements if this is air-drying phase
|
||||
if (phase === 'air-drying' && diameterMeasurements.some(m => m > 0)) {
|
||||
await phaseDraftManagement.saveDiameterMeasurements(savedData.id, diameterMeasurements)
|
||||
}
|
||||
|
||||
setLastSaved(new Date())
|
||||
onDataSaved()
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to save data'
|
||||
setError(errorMessage)
|
||||
console.error('Save error:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectDraft = (draft: ExperimentPhaseDraft) => {
|
||||
setSelectedDraft(draft)
|
||||
setShowDraftManager(false)
|
||||
loadPhaseDataForDraft(draft)
|
||||
}
|
||||
|
||||
const isFieldDisabled = () => {
|
||||
const isAdmin = currentUser.roles.includes('admin')
|
||||
return !selectedDraft ||
|
||||
selectedDraft.status === 'submitted' ||
|
||||
selectedDraft.status === 'withdrawn' ||
|
||||
(repetition.is_locked && !isAdmin)
|
||||
}
|
||||
|
||||
const getPhaseTitle = () => {
|
||||
switch (phase) {
|
||||
case 'pre-soaking': return 'Pre-Soaking Phase'
|
||||
case 'air-drying': return 'Air-Drying Phase'
|
||||
case 'cracking': return 'Cracking Phase'
|
||||
case 'shelling': return 'Shelling Phase'
|
||||
default: return 'Unknown Phase'
|
||||
}
|
||||
}
|
||||
|
||||
const calculateSoakingEndTime = () => {
|
||||
if (phaseData.soaking_start_time && experiment.soaking_duration_hr) {
|
||||
const startTime = new Date(phaseData.soaking_start_time)
|
||||
const endTime = new Date(startTime.getTime() + experiment.soaking_duration_hr * 60 * 60 * 1000)
|
||||
return endTime.toISOString().slice(0, 16) // Format for datetime-local input
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const calculateAirDryingEndTime = () => {
|
||||
if (phaseData.airdrying_start_time && experiment.air_drying_time_min) {
|
||||
const startTime = new Date(phaseData.airdrying_start_time)
|
||||
const endTime = new Date(startTime.getTime() + experiment.air_drying_time_min * 60 * 1000)
|
||||
return endTime.toISOString().slice(0, 16) // Format for datetime-local input
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading phase data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Draft Manager Modal */}
|
||||
{showDraftManager && (
|
||||
<PhaseDraftManager
|
||||
repetition={repetition}
|
||||
phase={phase}
|
||||
currentUser={currentUser}
|
||||
onSelectDraft={handleSelectDraft}
|
||||
onClose={() => setShowDraftManager(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-gray-600 hover:text-gray-900 mb-2"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Phases
|
||||
</button>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{getPhaseTitle()}</h2>
|
||||
{selectedDraft && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
Draft: {selectedDraft.draft_name || `Draft ${selectedDraft.id.slice(-8)}`}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${selectedDraft.status === 'draft' ? 'bg-yellow-100 text-yellow-800' :
|
||||
selectedDraft.status === 'submitted' ? 'bg-green-100 text-green-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{selectedDraft.status}
|
||||
</span>
|
||||
{repetition.is_locked && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
|
||||
🔒 Locked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setShowDraftManager(true)}
|
||||
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Manage Drafts
|
||||
</button>
|
||||
{lastSaved && (
|
||||
<span className="text-sm text-gray-500">
|
||||
Last saved: {lastSaved.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !selectedDraft || selectedDraft.status === 'submitted'}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDraft?.status === 'submitted' && (
|
||||
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<div className="text-sm text-yellow-700">
|
||||
This draft has been submitted and is read-only. Create a new draft to make changes.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDraft?.status === 'withdrawn' && (
|
||||
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">
|
||||
This draft has been withdrawn. Create a new draft to make changes.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repetition.is_locked && !currentUser.roles.includes('admin') && (
|
||||
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">
|
||||
This repetition has been locked by an admin. No changes can be made to drafts.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repetition.is_locked && currentUser.roles.includes('admin') && (
|
||||
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<div className="text-sm text-yellow-700">
|
||||
🔒 This repetition is locked, but you can still make changes as an admin.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedDraft && (
|
||||
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div className="text-sm text-blue-700">
|
||||
No draft selected. Use "Manage Drafts" to create or select a draft for this phase.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phase-specific forms */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
{phase === 'pre-soaking' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Pre-Soaking Measurements</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Batch Initial Weight (lbs) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.batch_initial_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('batch_initial_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Initial Shell Moisture (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={phaseData.initial_shell_moisture_pct || ''}
|
||||
onChange={(e) => handleInputChange('initial_shell_moisture_pct', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.0"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Initial Kernel Moisture (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={phaseData.initial_kernel_moisture_pct || ''}
|
||||
onChange={(e) => handleInputChange('initial_kernel_moisture_pct', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.0"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Soaking Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={phaseData.soaking_start_time ? new Date(phaseData.soaking_start_time).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => handleInputChange('soaking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calculated Soaking End Time */}
|
||||
{phaseData.soaking_start_time && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Soaking End Time (Calculated)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={calculateSoakingEndTime()}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Automatically calculated based on soaking duration ({experiment.soaking_duration_hr}h)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'air-drying' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Air-Drying Measurements</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Air-Drying Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={phaseData.airdrying_start_time ? new Date(phaseData.airdrying_start_time).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => handleInputChange('airdrying_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Post-Soak Weight (lbs)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.post_soak_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('post_soak_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Post-Soak Kernel Moisture (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={phaseData.post_soak_kernel_moisture_pct || ''}
|
||||
onChange={(e) => handleInputChange('post_soak_kernel_moisture_pct', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.0"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Post-Soak Shell Moisture (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={phaseData.post_soak_shell_moisture_pct || ''}
|
||||
onChange={(e) => handleInputChange('post_soak_shell_moisture_pct', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.0"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calculated Air-Drying End Time */}
|
||||
{phaseData.airdrying_start_time && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Air-Drying End Time (Calculated)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={calculateAirDryingEndTime()}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Automatically calculated based on air-drying duration ({experiment.air_drying_time_min} minutes)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pecan Diameter Measurements */}
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Pecan Diameter Measurements (inches)</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{diameterMeasurements.map((measurement, index) => (
|
||||
<div key={index}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Measurement {index + 1}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
value={measurement || ''}
|
||||
onChange={(e) => handleDiameterChange(index, parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.000"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Average Diameter Display */}
|
||||
<div className="mt-4 bg-blue-50 rounded-lg p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Average Pecan Diameter (Calculated)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={phaseData.avg_pecan_diameter_in || ''}
|
||||
onChange={(e) => handleInputChange('avg_pecan_diameter_in', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.000"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Automatically calculated from individual measurements above
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'cracking' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Cracking Phase</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Cracking Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={phaseData.cracking_start_time ? new Date(phaseData.cracking_start_time).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => handleInputChange('cracking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Machine Parameters Display */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-md font-medium text-gray-700 mb-3">Cracker Machine Parameters (Read-Only)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Plate Contact Frequency (Hz)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={experiment.plate_contact_frequency_hz}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Throughput Rate (pecans/sec)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={experiment.throughput_rate_pecans_sec}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Crush Amount (inches)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={experiment.crush_amount_in}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Entry/Exit Height Difference (inches)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={experiment.entry_exit_height_diff_in}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'shelling' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Shelling Phase</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Shelling Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={phaseData.shelling_start_time ? new Date(phaseData.shelling_start_time).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => handleInputChange('shelling_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bin Weights */}
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Bin Weights (lbs)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 1 Weight
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_1_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('bin_1_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 2 Weight
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_2_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('bin_2_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 3 Weight
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_3_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('bin_3_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Discharge Bin Weight
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.discharge_bin_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('discharge_bin_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Yield Weights */}
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Full Yield Weights (oz)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 1 Full Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_1_full_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_1_full_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 2 Full Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_2_full_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_2_full_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 3 Full Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_3_full_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_3_full_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Half Yield Weights */}
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Half Yield Weights (oz)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 1 Half Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_1_half_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_1_half_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 2 Half Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_2_half_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_2_half_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 3 Half Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_3_half_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_3_half_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
276
web/src/components/PhaseDraftManager.tsx
Normal file
276
web/src/components/PhaseDraftManager.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { phaseDraftManagement, type ExperimentPhaseDraft, type ExperimentPhase, type User, type ExperimentRepetition } from '../lib/supabase'
|
||||
|
||||
interface PhaseDraftManagerProps {
|
||||
repetition: ExperimentRepetition
|
||||
phase: ExperimentPhase
|
||||
currentUser: User
|
||||
onSelectDraft: (draft: ExperimentPhaseDraft) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function PhaseDraftManager({ repetition, phase, currentUser, onSelectDraft, onClose }: PhaseDraftManagerProps) {
|
||||
const [drafts, setDrafts] = useState<ExperimentPhaseDraft[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newDraftName, setNewDraftName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadDrafts()
|
||||
}, [repetition.id, phase])
|
||||
|
||||
const loadDrafts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const userDrafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase)
|
||||
setDrafts(userDrafts)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load drafts')
|
||||
console.error('Load drafts error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateDraft = async () => {
|
||||
try {
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
|
||||
const newDraft = await phaseDraftManagement.createPhaseDraft({
|
||||
experiment_id: repetition.experiment_id,
|
||||
repetition_id: repetition.id,
|
||||
phase_name: phase,
|
||||
draft_name: newDraftName || undefined,
|
||||
status: 'draft'
|
||||
})
|
||||
|
||||
setDrafts(prev => [newDraft, ...prev])
|
||||
setNewDraftName('')
|
||||
onSelectDraft(newDraft)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create draft')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteDraft = async (draftId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this draft? This action cannot be undone.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await phaseDraftManagement.deletePhaseDraft(draftId)
|
||||
setDrafts(prev => prev.filter(draft => draft.id !== draftId))
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete draft')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitDraft = async (draftId: string) => {
|
||||
if (!confirm('Are you sure you want to submit this draft? Once submitted, it can only be withdrawn by you or locked by an admin.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const submittedDraft = await phaseDraftManagement.submitPhaseDraft(draftId)
|
||||
setDrafts(prev => prev.map(draft =>
|
||||
draft.id === draftId ? submittedDraft : draft
|
||||
))
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to submit draft')
|
||||
}
|
||||
}
|
||||
|
||||
const handleWithdrawDraft = async (draftId: string) => {
|
||||
if (!confirm('Are you sure you want to withdraw this submitted draft? It will be marked as withdrawn.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const withdrawnDraft = await phaseDraftManagement.withdrawPhaseDraft(draftId)
|
||||
setDrafts(prev => prev.map(draft =>
|
||||
draft.id === draftId ? withdrawnDraft : draft
|
||||
))
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to withdraw draft')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">Draft</span>
|
||||
case 'submitted':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">Submitted</span>
|
||||
case 'withdrawn':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">Withdrawn</span>
|
||||
default:
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">{status}</span>
|
||||
}
|
||||
}
|
||||
|
||||
const canDeleteDraft = (draft: ExperimentPhaseDraft) => {
|
||||
return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin'))
|
||||
}
|
||||
|
||||
const canSubmitDraft = (draft: ExperimentPhaseDraft) => {
|
||||
return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin'))
|
||||
}
|
||||
|
||||
const canWithdrawDraft = (draft: ExperimentPhaseDraft) => {
|
||||
return draft.status === 'submitted' && (!repetition.is_locked || currentUser.roles.includes('admin'))
|
||||
}
|
||||
|
||||
const canCreateDraft = () => {
|
||||
return !repetition.is_locked || currentUser.roles.includes('admin')
|
||||
}
|
||||
|
||||
const formatPhaseTitle = (phase: string) => {
|
||||
return phase.split('-').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{formatPhaseTitle(phase)} Phase Drafts
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Repetition {repetition.repetition_number}
|
||||
{repetition.is_locked && (
|
||||
<span className="ml-2 px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
|
||||
🔒 Locked
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create New Draft */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Create New Draft</h3>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Draft name (optional)"
|
||||
value={newDraftName}
|
||||
onChange={(e) => setNewDraftName(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={creating || repetition.is_locked}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateDraft}
|
||||
disabled={creating || !canCreateDraft()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Draft'}
|
||||
</button>
|
||||
</div>
|
||||
{repetition.is_locked && !currentUser.roles.includes('admin') && (
|
||||
<p className="text-xs text-red-600 mt-2">
|
||||
Cannot create new drafts: repetition is locked by admin
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drafts List */}
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-gray-500">Loading drafts...</div>
|
||||
</div>
|
||||
) : drafts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-gray-500">No drafts found for this phase</div>
|
||||
<p className="text-sm text-gray-400 mt-1">Create a new draft to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
drafts.map((draft) => (
|
||||
<div key={draft.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-medium text-gray-900">
|
||||
{draft.draft_name || `Draft ${draft.id.slice(-8)}`}
|
||||
</h4>
|
||||
{getStatusBadge(draft.status)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>Created: {new Date(draft.created_at).toLocaleString()}</p>
|
||||
<p>Updated: {new Date(draft.updated_at).toLocaleString()}</p>
|
||||
{draft.submitted_at && (
|
||||
<p>Submitted: {new Date(draft.submitted_at).toLocaleString()}</p>
|
||||
)}
|
||||
{draft.withdrawn_at && (
|
||||
<p>Withdrawn: {new Date(draft.withdrawn_at).toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onSelectDraft(draft)}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{draft.status === 'draft' ? 'Edit' : 'View'}
|
||||
</button>
|
||||
|
||||
{canSubmitDraft(draft) && (
|
||||
<button
|
||||
onClick={() => handleSubmitDraft(draft.id)}
|
||||
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canWithdrawDraft(draft) && (
|
||||
<button
|
||||
onClick={() => handleWithdrawDraft(draft.id)}
|
||||
className="px-3 py-1 text-sm bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canDeleteDraft(draft) && (
|
||||
<button
|
||||
onClick={() => handleDeleteDraft(draft.id)}
|
||||
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
246
web/src/components/PhaseSelector.tsx
Normal file
246
web/src/components/PhaseSelector.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase'
|
||||
|
||||
// DEPRECATED: This component is deprecated in favor of RepetitionPhaseSelector
|
||||
// which uses the new phase-specific draft system
|
||||
|
||||
// Temporary type for backward compatibility
|
||||
interface LegacyDataEntry {
|
||||
id: string
|
||||
experiment_id: string
|
||||
user_id: string
|
||||
status: 'draft' | 'submitted'
|
||||
entry_name?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at?: string | null
|
||||
}
|
||||
|
||||
interface PhaseSelectorProps {
|
||||
dataEntry: LegacyDataEntry
|
||||
onPhaseSelect: (phase: ExperimentPhase) => void
|
||||
}
|
||||
|
||||
interface PhaseInfo {
|
||||
name: ExperimentPhase
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const phases: PhaseInfo[] = [
|
||||
{
|
||||
name: 'pre-soaking',
|
||||
title: 'Pre-Soaking',
|
||||
description: 'Initial measurements before soaking process',
|
||||
icon: '🌰',
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
name: 'air-drying',
|
||||
title: 'Air-Drying',
|
||||
description: 'Post-soak measurements and air-drying data',
|
||||
icon: '💨',
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
name: 'cracking',
|
||||
title: 'Cracking',
|
||||
description: 'Cracking process timing and parameters',
|
||||
icon: '🔨',
|
||||
color: 'bg-yellow-500'
|
||||
},
|
||||
{
|
||||
name: 'shelling',
|
||||
title: 'Shelling',
|
||||
description: 'Final measurements and yield data',
|
||||
icon: '📊',
|
||||
color: 'bg-purple-500'
|
||||
}
|
||||
]
|
||||
|
||||
export function PhaseSelector({ dataEntry, onPhaseSelect }: PhaseSelectorProps) {
|
||||
const [phaseData, setPhaseData] = useState<Record<ExperimentPhase, ExperimentPhaseData | null>>({
|
||||
'pre-soaking': null,
|
||||
'air-drying': null,
|
||||
'cracking': null,
|
||||
'shelling': null
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadPhaseData()
|
||||
}, [dataEntry.id])
|
||||
|
||||
const loadPhaseData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
// DEPRECATED: Using empty array since this component is deprecated
|
||||
const allPhaseData: ExperimentPhaseData[] = []
|
||||
|
||||
const phaseDataMap: Record<ExperimentPhase, ExperimentPhaseData | null> = {
|
||||
'pre-soaking': null,
|
||||
'air-drying': null,
|
||||
'cracking': null,
|
||||
'shelling': null
|
||||
}
|
||||
|
||||
allPhaseData.forEach(data => {
|
||||
phaseDataMap[data.phase_name] = data
|
||||
})
|
||||
|
||||
setPhaseData(phaseDataMap)
|
||||
} catch (error) {
|
||||
console.error('Failed to load phase data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPhaseCompletionStatus = (phaseName: ExperimentPhase): 'empty' | 'partial' | 'complete' => {
|
||||
const data = phaseData[phaseName]
|
||||
if (!data) return 'empty'
|
||||
|
||||
// Check if phase has any data
|
||||
const hasAnyData = Object.entries(data).some(([key, value]) => {
|
||||
if (['id', 'data_entry_id', 'phase_name', 'created_at', 'updated_at', 'diameter_measurements'].includes(key)) {
|
||||
return false
|
||||
}
|
||||
return value !== null && value !== undefined && value !== ''
|
||||
})
|
||||
|
||||
if (!hasAnyData) return 'empty'
|
||||
|
||||
// For now, consider any data as partial completion
|
||||
// You could implement more sophisticated completion logic here
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: 'empty' | 'partial' | 'complete') => {
|
||||
switch (status) {
|
||||
case 'empty':
|
||||
return (
|
||||
<div className="w-6 h-6 rounded-full border-2 border-gray-300 bg-white"></div>
|
||||
)
|
||||
case 'partial':
|
||||
return (
|
||||
<div className="w-6 h-6 rounded-full bg-yellow-500 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
case 'complete':
|
||||
return (
|
||||
<div className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getLastUpdated = (phaseName: ExperimentPhase): string | null => {
|
||||
const data = phaseData[phaseName]
|
||||
if (!data) return null
|
||||
return new Date(data.updated_at).toLocaleString()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading phase data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Select Experiment Phase</h2>
|
||||
<p className="text-gray-600">
|
||||
Click on any phase card to enter or edit data for that phase
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{phases.map((phase) => {
|
||||
const status = getPhaseCompletionStatus(phase.name)
|
||||
const lastUpdated = getLastUpdated(phase.name)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={phase.name}
|
||||
onClick={() => onPhaseSelect(phase.name)}
|
||||
className="text-left p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200 hover:border-gray-300"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-12 h-12 rounded-lg ${phase.color} flex items-center justify-center text-white text-xl mr-4`}>
|
||||
{phase.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">{phase.title}</h3>
|
||||
<p className="text-sm text-gray-500">{phase.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
{getStatusIcon(status)}
|
||||
<span className="text-xs text-gray-400 mt-1">
|
||||
{status === 'empty' ? 'No data' : status === 'partial' ? 'In progress' : 'Complete'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastUpdated && (
|
||||
<div className="text-xs text-gray-400">
|
||||
Last updated: {lastUpdated}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center text-blue-600">
|
||||
<span className="text-sm font-medium">
|
||||
{status === 'empty' ? 'Start entering data' : 'Continue editing'}
|
||||
</span>
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Phase Navigation */}
|
||||
<div className="mt-8 bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Phase Progress</h3>
|
||||
<div className="flex items-center space-x-4">
|
||||
{phases.map((phase, index) => {
|
||||
const status = getPhaseCompletionStatus(phase.name)
|
||||
return (
|
||||
<div key={phase.name} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onPhaseSelect(phase.name)}
|
||||
className="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{getStatusIcon(status)}
|
||||
<span className="text-sm text-gray-700">{phase.title}</span>
|
||||
</button>
|
||||
{index < phases.length - 1 && (
|
||||
<svg className="w-4 h-4 text-gray-400 mx-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
web/src/components/RepetitionDataEntryInterface.tsx
Normal file
115
web/src/components/RepetitionDataEntryInterface.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { type Experiment, type ExperimentRepetition, type User, type ExperimentPhase } from '../lib/supabase'
|
||||
import { RepetitionPhaseSelector } from './RepetitionPhaseSelector'
|
||||
import { PhaseDataEntry } from './PhaseDataEntry'
|
||||
import { RepetitionLockManager } from './RepetitionLockManager'
|
||||
|
||||
interface RepetitionDataEntryInterfaceProps {
|
||||
experiment: Experiment
|
||||
repetition: ExperimentRepetition
|
||||
currentUser: User
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function RepetitionDataEntryInterface({ experiment, repetition, currentUser, onBack }: RepetitionDataEntryInterfaceProps) {
|
||||
const [selectedPhase, setSelectedPhase] = useState<ExperimentPhase | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentRepetition, setCurrentRepetition] = useState<ExperimentRepetition>(repetition)
|
||||
|
||||
useEffect(() => {
|
||||
// Skip loading old data entries - go directly to phase selection
|
||||
setLoading(false)
|
||||
}, [repetition.id, currentUser.id])
|
||||
|
||||
|
||||
|
||||
const handlePhaseSelect = (phase: ExperimentPhase) => {
|
||||
setSelectedPhase(phase)
|
||||
}
|
||||
|
||||
const handleBackToPhases = () => {
|
||||
setSelectedPhase(null)
|
||||
}
|
||||
|
||||
const handleRepetitionUpdated = (updatedRepetition: ExperimentRepetition) => {
|
||||
setCurrentRepetition(updatedRepetition)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-blue-600 hover:text-blue-800 flex items-center space-x-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to Repetitions</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number}
|
||||
</h1>
|
||||
<div className="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<div>Soaking: {experiment.soaking_duration_hr}h • Air Drying: {experiment.air_drying_time_min}min</div>
|
||||
<div>Frequency: {experiment.plate_contact_frequency_hz}Hz • Throughput: {experiment.throughput_rate_pecans_sec}/sec</div>
|
||||
{repetition.scheduled_date && (
|
||||
<div>Scheduled: {new Date(repetition.scheduled_date).toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No additional controls needed - phase-specific draft management is handled within each phase */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Controls */}
|
||||
<RepetitionLockManager
|
||||
repetition={currentRepetition}
|
||||
currentUser={currentUser}
|
||||
onRepetitionUpdated={handleRepetitionUpdated}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
{selectedPhase ? (
|
||||
<PhaseDataEntry
|
||||
experiment={experiment}
|
||||
repetition={currentRepetition}
|
||||
phase={selectedPhase}
|
||||
currentUser={currentUser}
|
||||
onBack={handleBackToPhases}
|
||||
onDataSaved={() => {
|
||||
// Data is automatically saved in the new phase-specific system
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<RepetitionPhaseSelector
|
||||
repetition={currentRepetition}
|
||||
currentUser={currentUser}
|
||||
onPhaseSelect={handlePhaseSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
web/src/components/RepetitionLockManager.tsx
Normal file
124
web/src/components/RepetitionLockManager.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { repetitionManagement, type ExperimentRepetition, type User } from '../lib/supabase'
|
||||
|
||||
interface RepetitionLockManagerProps {
|
||||
repetition: ExperimentRepetition
|
||||
currentUser: User
|
||||
onRepetitionUpdated: (updatedRepetition: ExperimentRepetition) => void
|
||||
}
|
||||
|
||||
export function RepetitionLockManager({ repetition, currentUser, onRepetitionUpdated }: RepetitionLockManagerProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isAdmin = currentUser.roles.includes('admin')
|
||||
|
||||
const handleLockRepetition = async () => {
|
||||
if (!confirm('Are you sure you want to lock this repetition? This will prevent users from modifying or withdrawing any submitted drafts.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const updatedRepetition = await repetitionManagement.lockRepetition(repetition.id)
|
||||
onRepetitionUpdated(updatedRepetition)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to lock repetition')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlockRepetition = async () => {
|
||||
if (!confirm('Are you sure you want to unlock this repetition? This will allow users to modify and withdraw submitted drafts again.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const updatedRepetition = await repetitionManagement.unlockRepetition(repetition.id)
|
||||
onRepetitionUpdated(updatedRepetition)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to unlock repetition')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Admin Controls</h3>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700">Repetition Status:</span>
|
||||
{repetition.is_locked ? (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
|
||||
🔒 Locked
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
|
||||
🔓 Unlocked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{repetition.is_locked && repetition.locked_at && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Locked: {new Date(repetition.locked_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{repetition.is_locked ? (
|
||||
<button
|
||||
onClick={handleUnlockRepetition}
|
||||
disabled={loading}
|
||||
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Unlocking...' : 'Unlock'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleLockRepetition}
|
||||
disabled={loading}
|
||||
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Locking...' : 'Lock'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
{repetition.is_locked ? (
|
||||
<p>
|
||||
When locked, users cannot create new drafts, delete existing drafts, or withdraw submitted drafts.
|
||||
Only admins can modify the lock status.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
When unlocked, users can freely create, edit, delete, submit, and withdraw drafts.
|
||||
Lock this repetition to prevent further changes to submitted data.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
223
web/src/components/RepetitionPhaseSelector.tsx
Normal file
223
web/src/components/RepetitionPhaseSelector.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { phaseDraftManagement, type ExperimentRepetition, type ExperimentPhase, type ExperimentPhaseDraft, type User } from '../lib/supabase'
|
||||
|
||||
interface RepetitionPhaseSelectorProps {
|
||||
repetition: ExperimentRepetition
|
||||
currentUser: User
|
||||
onPhaseSelect: (phase: ExperimentPhase) => void
|
||||
}
|
||||
|
||||
interface PhaseInfo {
|
||||
name: ExperimentPhase
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const phases: PhaseInfo[] = [
|
||||
{
|
||||
name: 'pre-soaking',
|
||||
title: 'Pre-Soaking',
|
||||
description: 'Initial measurements before soaking process',
|
||||
icon: '🌰',
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
name: 'air-drying',
|
||||
title: 'Air-Drying',
|
||||
description: 'Post-soak measurements and air-drying data',
|
||||
icon: '💨',
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
name: 'cracking',
|
||||
title: 'Cracking',
|
||||
description: 'Cracking process timing and parameters',
|
||||
icon: '🔨',
|
||||
color: 'bg-yellow-500'
|
||||
},
|
||||
{
|
||||
name: 'shelling',
|
||||
title: 'Shelling',
|
||||
description: 'Final measurements and yield data',
|
||||
icon: '📊',
|
||||
color: 'bg-purple-500'
|
||||
}
|
||||
]
|
||||
|
||||
export function RepetitionPhaseSelector({ repetition, currentUser: _currentUser, onPhaseSelect }: RepetitionPhaseSelectorProps) {
|
||||
const [phaseDrafts, setPhaseDrafts] = useState<Record<ExperimentPhase, ExperimentPhaseDraft[]>>({
|
||||
'pre-soaking': [],
|
||||
'air-drying': [],
|
||||
'cracking': [],
|
||||
'shelling': []
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadPhaseDrafts()
|
||||
}, [repetition.id])
|
||||
|
||||
const loadPhaseDrafts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const allDrafts = await phaseDraftManagement.getUserPhaseDraftsForRepetition(repetition.id)
|
||||
|
||||
// Group drafts by phase
|
||||
const groupedDrafts: Record<ExperimentPhase, ExperimentPhaseDraft[]> = {
|
||||
'pre-soaking': [],
|
||||
'air-drying': [],
|
||||
'cracking': [],
|
||||
'shelling': []
|
||||
}
|
||||
|
||||
allDrafts.forEach(draft => {
|
||||
groupedDrafts[draft.phase_name].push(draft)
|
||||
})
|
||||
|
||||
setPhaseDrafts(groupedDrafts)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load phase drafts')
|
||||
console.error('Load phase drafts error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPhaseStatus = (phase: ExperimentPhase) => {
|
||||
const drafts = phaseDrafts[phase]
|
||||
if (drafts.length === 0) return 'empty'
|
||||
|
||||
const hasSubmitted = drafts.some(d => d.status === 'submitted')
|
||||
const hasDraft = drafts.some(d => d.status === 'draft')
|
||||
const hasWithdrawn = drafts.some(d => d.status === 'withdrawn')
|
||||
|
||||
if (hasSubmitted) return 'submitted'
|
||||
if (hasDraft) return 'draft'
|
||||
if (hasWithdrawn) return 'withdrawn'
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">Submitted</span>
|
||||
case 'draft':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">Draft</span>
|
||||
case 'withdrawn':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">Withdrawn</span>
|
||||
case 'empty':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">No Data</span>
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getDraftCount = (phase: ExperimentPhase) => {
|
||||
return phaseDrafts[phase].length
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading phases...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Select Phase</h2>
|
||||
<p className="text-gray-600">
|
||||
Choose a phase to enter or view data. Each phase can have multiple drafts.
|
||||
</p>
|
||||
{repetition.is_locked && (
|
||||
<div className="mt-2 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="flex items-center">
|
||||
<span className="text-red-800 text-sm font-medium">🔒 This repetition is locked by an admin</span>
|
||||
</div>
|
||||
<p className="text-red-700 text-xs mt-1">
|
||||
You can view existing data but cannot create new drafts or modify existing ones.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{phases.map((phase) => {
|
||||
const status = getPhaseStatus(phase.name)
|
||||
const draftCount = getDraftCount(phase.name)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={phase.name}
|
||||
onClick={() => onPhaseSelect(phase.name)}
|
||||
className="bg-white rounded-lg shadow-md border border-gray-200 p-6 cursor-pointer hover:shadow-lg hover:border-blue-300 transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-12 h-12 ${phase.color} rounded-lg flex items-center justify-center text-white text-xl mr-4`}>
|
||||
{phase.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{phase.title}</h3>
|
||||
<p className="text-sm text-gray-600">{phase.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(status)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>
|
||||
{draftCount === 0 ? 'No drafts' : `${draftCount} draft${draftCount === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{draftCount > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{phaseDrafts[phase.name].slice(0, 3).map((draft, index) => (
|
||||
<span
|
||||
key={draft.id}
|
||||
className={`px-2 py-1 text-xs rounded ${draft.status === 'submitted' ? 'bg-green-100 text-green-700' :
|
||||
draft.status === 'draft' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{draft.draft_name || `Draft ${index + 1}`}
|
||||
</span>
|
||||
))}
|
||||
{draftCount > 3 && (
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
+{draftCount - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
225
web/src/components/RepetitionScheduleModal.tsx
Normal file
225
web/src/components/RepetitionScheduleModal.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState } from 'react'
|
||||
import { repetitionManagement } from '../lib/supabase'
|
||||
import type { Experiment, ExperimentRepetition } from '../lib/supabase'
|
||||
|
||||
interface RepetitionScheduleModalProps {
|
||||
experiment: Experiment
|
||||
repetition: ExperimentRepetition
|
||||
onClose: () => void
|
||||
onScheduleUpdated: (updatedRepetition: ExperimentRepetition) => void
|
||||
}
|
||||
|
||||
export function RepetitionScheduleModal({ experiment, repetition, onClose, onScheduleUpdated }: RepetitionScheduleModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Initialize with existing scheduled date or current date/time
|
||||
const getInitialDateTime = () => {
|
||||
if (repetition.scheduled_date) {
|
||||
const date = new Date(repetition.scheduled_date)
|
||||
return {
|
||||
date: date.toISOString().split('T')[0],
|
||||
time: date.toTimeString().slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
// Set to next hour by default
|
||||
now.setHours(now.getHours() + 1, 0, 0, 0)
|
||||
return {
|
||||
date: now.toISOString().split('T')[0],
|
||||
time: now.toTimeString().slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
const [dateTime, setDateTime] = useState(getInitialDateTime())
|
||||
const isScheduled = repetition.scheduled_date && repetition.schedule_status === 'scheduled'
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Validate date/time
|
||||
const selectedDateTime = new Date(`${dateTime.date}T${dateTime.time}`)
|
||||
const now = new Date()
|
||||
|
||||
if (selectedDateTime <= now) {
|
||||
setError('Scheduled date and time must be in the future')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule the repetition
|
||||
const updatedRepetition = await repetitionManagement.scheduleRepetition(
|
||||
repetition.id,
|
||||
selectedDateTime.toISOString()
|
||||
)
|
||||
|
||||
onScheduleUpdated(updatedRepetition)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to schedule repetition')
|
||||
console.error('Schedule repetition error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveSchedule = async () => {
|
||||
if (!confirm('Are you sure you want to remove the schedule for this repetition?')) {
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const updatedRepetition = await repetitionManagement.removeRepetitionSchedule(repetition.id)
|
||||
onScheduleUpdated(updatedRepetition)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to remove schedule')
|
||||
console.error('Remove schedule error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto max-h-[90vh] overflow-y-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white/90">
|
||||
Schedule Repetition
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Experiment and Repetition Info */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">
|
||||
Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{experiment.reps_required} reps required • {experiment.soaking_duration_hr}h soaking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Schedule (if exists) */}
|
||||
{isScheduled && (
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||
<h5 className="font-medium text-blue-900 mb-1">Currently Scheduled</h5>
|
||||
<p className="text-sm text-blue-700">
|
||||
{new Date(repetition.scheduled_date!).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
value={dateTime.date}
|
||||
onChange={(e) => setDateTime({ ...dateTime, date: e.target.value })}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="time" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Time *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
id="time"
|
||||
value={dateTime.time}
|
||||
onChange={(e) => setDateTime({ ...dateTime, time: e.target.value })}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<div>
|
||||
{isScheduled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveSchedule}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Remove Schedule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Scheduling...' : (isScheduled ? 'Update Schedule' : 'Schedule Repetition')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
web/src/components/ScheduleModal.tsx
Normal file
237
web/src/components/ScheduleModal.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState } from 'react'
|
||||
import { experimentManagement } from '../lib/supabase'
|
||||
import type { Experiment } from '../lib/supabase'
|
||||
|
||||
interface ScheduleModalProps {
|
||||
experiment: Experiment
|
||||
onClose: () => void
|
||||
onScheduleUpdated: (experiment: Experiment) => void
|
||||
}
|
||||
|
||||
export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: ScheduleModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Initialize with existing scheduled date or current date/time
|
||||
const getInitialDateTime = () => {
|
||||
if (experiment.scheduled_date) {
|
||||
const date = new Date(experiment.scheduled_date)
|
||||
return {
|
||||
date: date.toISOString().split('T')[0],
|
||||
time: date.toTimeString().slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
// Set to next hour by default
|
||||
now.setHours(now.getHours() + 1, 0, 0, 0)
|
||||
return {
|
||||
date: now.toISOString().split('T')[0],
|
||||
time: now.toTimeString().slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
const [dateTime, setDateTime] = useState(getInitialDateTime())
|
||||
|
||||
const isScheduled = !!experiment.scheduled_date
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Validate date/time
|
||||
const selectedDateTime = new Date(`${dateTime.date}T${dateTime.time}`)
|
||||
const now = new Date()
|
||||
|
||||
if (selectedDateTime <= now) {
|
||||
setError('Scheduled date and time must be in the future')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule the experiment
|
||||
const updatedExperiment = await experimentManagement.scheduleExperiment(
|
||||
experiment.id,
|
||||
selectedDateTime.toISOString()
|
||||
)
|
||||
|
||||
onScheduleUpdated(updatedExperiment)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to schedule experiment')
|
||||
console.error('Schedule experiment error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveSchedule = async () => {
|
||||
if (!confirm('Are you sure you want to remove the schedule for this experiment?')) {
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const updatedExperiment = await experimentManagement.removeExperimentSchedule(experiment.id)
|
||||
onScheduleUpdated(updatedExperiment)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to remove schedule')
|
||||
console.error('Remove schedule error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">
|
||||
{isScheduled ? 'Update Schedule' : 'Schedule Experiment'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Experiment Info */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Experiment #{experiment.experiment_number}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{experiment.reps_required} reps required • {experiment.soaking_duration_hr}h soaking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Schedule (if exists) */}
|
||||
{isScheduled && (
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||
<h5 className="font-medium text-blue-900 mb-1">Currently Scheduled</h5>
|
||||
<p className="text-sm text-blue-700">
|
||||
{new Date(experiment.scheduled_date!).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Date *
|
||||
</label>
|
||||
<div className="relative max-w-xs">
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
value={dateTime.date}
|
||||
onChange={(e) => setDateTime({ ...dateTime, date: e.target.value })}
|
||||
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
|
||||
required
|
||||
/>
|
||||
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
|
||||
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="time" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Time *
|
||||
</label>
|
||||
<div className="relative max-w-xs">
|
||||
<input
|
||||
type="time"
|
||||
id="time"
|
||||
value={dateTime.time}
|
||||
onChange={(e) => setDateTime({ ...dateTime, time: e.target.value })}
|
||||
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
|
||||
required
|
||||
/>
|
||||
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
|
||||
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<div>
|
||||
{isScheduled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveSchedule}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-error-600 hover:text-error-700 hover:bg-error-50 dark:text-error-500 dark:hover:bg-error-500/15 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Remove Schedule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 focus:outline-none focus:ring-3 focus:ring-brand-500/10 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : (isScheduled ? 'Update Schedule' : 'Schedule Experiment')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
311
web/src/components/Sidebar.tsx
Normal file
311
web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import type { User } from '../lib/supabase'
|
||||
|
||||
interface SidebarProps {
|
||||
user: User
|
||||
currentView: string
|
||||
onViewChange: (view: string) => void
|
||||
isExpanded?: boolean
|
||||
isMobileOpen?: boolean
|
||||
isHovered?: boolean
|
||||
setIsHovered?: (hovered: boolean) => void
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ReactElement
|
||||
requiredRoles?: string[]
|
||||
subItems?: { name: string; id: string; requiredRoles?: string[] }[]
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
user,
|
||||
currentView,
|
||||
onViewChange,
|
||||
isExpanded = true,
|
||||
isMobileOpen = false,
|
||||
isHovered = false,
|
||||
setIsHovered
|
||||
}: SidebarProps) {
|
||||
const [openSubmenu, setOpenSubmenu] = useState<number | null>(null)
|
||||
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({})
|
||||
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6a2 2 0 01-2 2H10a2 2 0 01-2-2V5z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'user-management',
|
||||
name: 'User Management',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
),
|
||||
requiredRoles: ['admin']
|
||||
},
|
||||
{
|
||||
id: 'experiments',
|
||||
name: 'Experiments',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
),
|
||||
requiredRoles: ['admin', 'conductor']
|
||||
},
|
||||
{
|
||||
id: 'video-library',
|
||||
name: 'Video Library',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
name: 'Analytics',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
requiredRoles: ['admin', 'conductor', 'analyst']
|
||||
},
|
||||
{
|
||||
id: 'data-entry',
|
||||
name: 'Data Entry',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
),
|
||||
requiredRoles: ['admin', 'conductor', 'data recorder']
|
||||
},
|
||||
{
|
||||
id: 'vision-system',
|
||||
name: 'Vision System',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
// const isActive = (path: string) => location.pathname === path;
|
||||
const isActive = useCallback(
|
||||
(id: string) => currentView === id,
|
||||
[currentView]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-open submenu if current view is in a submenu
|
||||
menuItems.forEach((nav, index) => {
|
||||
if (nav.subItems) {
|
||||
nav.subItems.forEach((subItem) => {
|
||||
if (isActive(subItem.id)) {
|
||||
setOpenSubmenu(index)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [currentView, isActive, menuItems])
|
||||
|
||||
useEffect(() => {
|
||||
if (openSubmenu !== null) {
|
||||
const key = `submenu-${openSubmenu}`
|
||||
if (subMenuRefs.current[key]) {
|
||||
setSubMenuHeight((prevHeights) => ({
|
||||
...prevHeights,
|
||||
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [openSubmenu])
|
||||
|
||||
const handleSubmenuToggle = (index: number) => {
|
||||
setOpenSubmenu((prevOpenSubmenu) => {
|
||||
if (prevOpenSubmenu === index) {
|
||||
return null
|
||||
}
|
||||
return index
|
||||
})
|
||||
}
|
||||
|
||||
const hasAccess = (item: MenuItem): boolean => {
|
||||
if (!item.requiredRoles) return true
|
||||
return item.requiredRoles.some(role => user.roles.includes(role as any))
|
||||
}
|
||||
|
||||
const renderMenuItems = (items: MenuItem[]) => (
|
||||
<ul className="flex flex-col gap-4">
|
||||
{items.map((nav, index) => {
|
||||
if (!hasAccess(nav)) return null
|
||||
|
||||
return (
|
||||
<li key={nav.id}>
|
||||
{nav.subItems ? (
|
||||
<button
|
||||
onClick={() => handleSubmenuToggle(index)}
|
||||
className={`menu-item group ${openSubmenu === index
|
||||
? "menu-item-active"
|
||||
: "menu-item-inactive"
|
||||
} cursor-pointer ${!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "lg:justify-start"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`menu-item-icon-size ${openSubmenu === index
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}`}
|
||||
>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<span className="menu-item-text">{nav.name}</span>
|
||||
)}
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<svg
|
||||
className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu === index
|
||||
? "rotate-180 text-brand-500"
|
||||
: ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onViewChange(nav.id)}
|
||||
className={`menu-item group ${isActive(nav.id) ? "menu-item-active" : "menu-item-inactive"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`menu-item-icon-size ${isActive(nav.id)
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}`}
|
||||
>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<span className="menu-item-text">{nav.name}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
subMenuRefs.current[`submenu-${index}`] = el
|
||||
}}
|
||||
className="overflow-hidden transition-all duration-300"
|
||||
style={{
|
||||
height:
|
||||
openSubmenu === index
|
||||
? `${subMenuHeight[`submenu-${index}`]}px`
|
||||
: "0px",
|
||||
}}
|
||||
>
|
||||
<ul className="mt-2 space-y-1 ml-9">
|
||||
{nav.subItems.map((subItem) => {
|
||||
if (subItem.requiredRoles && !subItem.requiredRoles.some(role => user.roles.includes(role as any))) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<li key={subItem.id}>
|
||||
<button
|
||||
onClick={() => onViewChange(subItem.id)}
|
||||
className={`menu-dropdown-item ${isActive(subItem.id)
|
||||
? "menu-dropdown-item-active"
|
||||
: "menu-dropdown-item-inactive"
|
||||
}`}
|
||||
>
|
||||
{subItem.name}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
|
||||
${isExpanded || isMobileOpen
|
||||
? "w-[290px]"
|
||||
: isHovered
|
||||
? "w-[290px]"
|
||||
: "w-[90px]"
|
||||
}
|
||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
lg:translate-x-0`}
|
||||
onMouseEnter={() => !isExpanded && setIsHovered && setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered && setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
<>
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-white/90">Pecan Experiments</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Research Dashboard</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center text-white font-bold text-lg">
|
||||
P
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
|
||||
<nav className="mb-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2
|
||||
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "justify-start"
|
||||
}`}
|
||||
>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
"Menu"
|
||||
) : (
|
||||
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</h2>
|
||||
{renderMenuItems(menuItems)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
269
web/src/components/TopNavbar.tsx
Normal file
269
web/src/components/TopNavbar.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { useState } from 'react'
|
||||
import type { User } from '../lib/supabase'
|
||||
|
||||
interface TopNavbarProps {
|
||||
user: User
|
||||
onLogout: () => void
|
||||
currentView?: string
|
||||
onToggleSidebar?: () => void
|
||||
isSidebarOpen?: boolean
|
||||
}
|
||||
|
||||
export function TopNavbar({
|
||||
user,
|
||||
onLogout,
|
||||
currentView = 'dashboard',
|
||||
onToggleSidebar,
|
||||
isSidebarOpen = false
|
||||
}: TopNavbarProps) {
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
|
||||
|
||||
const getPageTitle = (view: string) => {
|
||||
switch (view) {
|
||||
case 'dashboard':
|
||||
return 'Dashboard'
|
||||
case 'user-management':
|
||||
return 'User Management'
|
||||
case 'experiments':
|
||||
return 'Experiments'
|
||||
case 'analytics':
|
||||
return 'Analytics'
|
||||
case 'data-entry':
|
||||
return 'Data Entry'
|
||||
case 'vision-system':
|
||||
return 'Vision System'
|
||||
case 'video-library':
|
||||
return 'Video Library'
|
||||
default:
|
||||
return 'Dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
|
||||
case 'conductor':
|
||||
return 'bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400'
|
||||
case 'analyst':
|
||||
return 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
|
||||
case 'data recorder':
|
||||
return 'bg-theme-purple-500/10 text-theme-purple-500'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 flex w-full bg-white border-gray-200 z-99999 dark:border-gray-800 dark:bg-gray-900 lg:border-b">
|
||||
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
|
||||
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark:border-gray-800 sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
|
||||
<button
|
||||
className="items-center justify-center w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border"
|
||||
onClick={onToggleSidebar}
|
||||
aria-label="Toggle Sidebar"
|
||||
>
|
||||
{isSidebarOpen ? (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="12"
|
||||
viewBox="0 0 16 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Page title */}
|
||||
<div className="flex items-center lg:hidden">
|
||||
<h1 className="text-lg font-medium text-gray-800 dark:text-white/90">{getPageTitle(currentView)}</h1>
|
||||
</div>
|
||||
|
||||
{/* Search bar - hidden on mobile, shown on desktop */}
|
||||
<div className="hidden lg:block">
|
||||
<form>
|
||||
<div className="relative">
|
||||
<span className="absolute -translate-y-1/2 pointer-events-none left-4 top-1/2">
|
||||
<svg
|
||||
className="fill-gray-500 dark:fill-gray-400"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.04175 9.37363C3.04175 5.87693 5.87711 3.04199 9.37508 3.04199C12.8731 3.04199 15.7084 5.87693 15.7084 9.37363C15.7084 12.8703 12.8731 15.7053 9.37508 15.7053C5.87711 15.7053 3.04175 12.8703 3.04175 9.37363ZM9.37508 1.54199C5.04902 1.54199 1.54175 5.04817 1.54175 9.37363C1.54175 13.6991 5.04902 17.2053 9.37508 17.2053C11.2674 17.2053 13.003 16.5344 14.357 15.4176L17.177 18.238C17.4699 18.5309 17.9448 18.5309 18.2377 18.238C18.5306 17.9451 18.5306 17.4703 18.2377 17.1774L15.418 14.3573C16.5365 13.0033 17.2084 11.2669 17.2084 9.37363C17.2084 5.04817 13.7011 1.54199 9.37508 1.54199Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search or type command..."
|
||||
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]"
|
||||
/>
|
||||
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
|
||||
<span> ⌘ </span>
|
||||
<span> K </span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none">
|
||||
{/* User Area */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center text-gray-700 dropdown-toggle dark:text-gray-400"
|
||||
>
|
||||
<span className="mr-3 overflow-hidden rounded-full h-11 w-11">
|
||||
<div className="w-11 h-11 bg-brand-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
{user.email.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<span className="block mr-1 font-medium text-theme-sm">{user.email.split('@')[0]}</span>
|
||||
<svg
|
||||
className={`stroke-gray-500 dark:stroke-gray-400 transition-transform duration-200 ${isUserMenuOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
width="18"
|
||||
height="20"
|
||||
viewBox="0 0 18 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.3125 8.65625L9 13.3437L13.6875 8.65625"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isUserMenuOpen && (
|
||||
<div className="absolute right-0 mt-[17px] flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark">
|
||||
<div>
|
||||
<span className="block font-medium text-gray-700 text-theme-sm dark:text-gray-400">
|
||||
{user.email.split('@')[0]}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-theme-xs text-gray-500 dark:text-gray-400">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-1 pt-4 pb-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<li>
|
||||
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
|
||||
<svg
|
||||
className="fill-gray-500 dark:fill-gray-400"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 14.1526 4.3002 16.1184 5.61936 17.616C6.17279 15.3096 8.24852 13.5955 10.7246 13.5955H13.2746C15.7509 13.5955 17.8268 15.31 18.38 17.6167C19.6996 16.119 20.5 14.153 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5ZM17.0246 18.8566V18.8455C17.0246 16.7744 15.3457 15.0955 13.2746 15.0955H10.7246C8.65354 15.0955 6.97461 16.7744 6.97461 18.8455V18.856C8.38223 19.8895 10.1198 20.5 12 20.5C13.8798 20.5 15.6171 19.8898 17.0246 18.8566ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM11.9991 7.25C10.8847 7.25 9.98126 8.15342 9.98126 9.26784C9.98126 10.3823 10.8847 11.2857 11.9991 11.2857C13.1135 11.2857 14.0169 10.3823 14.0169 9.26784C14.0169 8.15342 13.1135 7.25 11.9991 7.25ZM8.48126 9.26784C8.48126 7.32499 10.0563 5.75 11.9991 5.75C13.9419 5.75 15.5169 7.32499 15.5169 9.26784C15.5169 11.2107 13.9419 12.7857 11.9991 12.7857C10.0563 12.7857 8.48126 11.2107 8.48126 9.26784Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Profile
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Status:</span>
|
||||
<span className={user.status === 'active' ? 'text-success-600 dark:text-success-500' : 'text-error-600 dark:text-error-500'}>
|
||||
{user.status}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">Roles:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
|
||||
>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false)
|
||||
onLogout()
|
||||
}}
|
||||
className="flex items-center gap-3 px-3 py-2 mt-3 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
className="fill-gray-500 group-hover:fill-gray-700 dark:group-hover:fill-gray-300"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.1007 19.247C14.6865 19.247 14.3507 18.9112 14.3507 18.497L14.3507 14.245H12.8507V18.497C12.8507 19.7396 13.8581 20.747 15.1007 20.747H18.5007C19.7434 20.747 20.7507 19.7396 20.7507 18.497L20.7507 5.49609C20.7507 4.25345 19.7433 3.24609 18.5007 3.24609H15.1007C13.8581 3.24609 12.8507 4.25345 12.8507 5.49609V9.74501L14.3507 9.74501V5.49609C14.3507 5.08188 14.6865 4.74609 15.1007 4.74609L18.5007 4.74609C18.9149 4.74609 19.2507 5.08188 19.2507 5.49609L19.2507 18.497C19.2507 18.9112 18.9149 19.247 18.5007 19.247H15.1007ZM3.25073 11.9984C3.25073 12.2144 3.34204 12.4091 3.48817 12.546L8.09483 17.1556C8.38763 17.4485 8.86251 17.4487 9.15549 17.1559C9.44848 16.8631 9.44863 16.3882 9.15583 16.0952L5.81116 12.7484L16.0007 12.7484C16.4149 12.7484 16.7507 12.4127 16.7507 11.9984C16.7507 11.5842 16.4149 11.2484 16.0007 11.2484L5.81528 11.2484L9.15585 7.90554C9.44864 7.61255 9.44847 7.13767 9.15547 6.84488C8.86248 6.55209 8.3876 6.55226 8.09481 6.84525L3.52309 11.4202C3.35673 11.5577 3.25073 11.7657 3.25073 11.9984Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Click outside to close dropdown */}
|
||||
{isUserMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
421
web/src/components/UserManagement.tsx
Normal file
421
web/src/components/UserManagement.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { userManagement, type User, type Role, type RoleName, type UserStatus } from '../lib/supabase'
|
||||
import { CreateUserModal } from './CreateUserModal'
|
||||
|
||||
export function UserManagement() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editingUser, setEditingUser] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [usersData, rolesData] = await Promise.all([
|
||||
userManagement.getAllUsers(),
|
||||
userManagement.getAllRoles()
|
||||
])
|
||||
|
||||
setUsers(usersData)
|
||||
setRoles(rolesData)
|
||||
} catch (err) {
|
||||
setError('Failed to load user data')
|
||||
console.error('Load data error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusToggle = async (userId: string, currentStatus: UserStatus) => {
|
||||
try {
|
||||
const newStatus: UserStatus = currentStatus === 'active' ? 'disabled' : 'active'
|
||||
await userManagement.updateUserStatus(userId, newStatus)
|
||||
|
||||
// Update local state
|
||||
setUsers(users.map(user =>
|
||||
user.id === userId ? { ...user, status: newStatus } : user
|
||||
))
|
||||
} catch (err) {
|
||||
console.error('Status update error:', err)
|
||||
alert('Failed to update user status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleUpdate = async (userId: string, newRoles: RoleName[]) => {
|
||||
try {
|
||||
await userManagement.updateUserRoles(userId, newRoles)
|
||||
|
||||
// Update local state
|
||||
setUsers(users.map(user =>
|
||||
user.id === userId ? { ...user, roles: newRoles } : user
|
||||
))
|
||||
|
||||
setEditingUser(null)
|
||||
} catch (err) {
|
||||
console.error('Role update error:', err)
|
||||
alert('Failed to update user roles')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmailUpdate = async (userId: string, newEmail: string) => {
|
||||
try {
|
||||
await userManagement.updateUserEmail(userId, newEmail)
|
||||
|
||||
// Update local state
|
||||
setUsers(users.map(user =>
|
||||
user.id === userId ? { ...user, email: newEmail } : user
|
||||
))
|
||||
|
||||
setEditingUser(null)
|
||||
} catch (err) {
|
||||
console.error('Email update error:', err)
|
||||
alert('Failed to update user email')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserCreated = (newUser: User) => {
|
||||
setUsers([...users, newUser])
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'conductor':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'analyst':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'data recorder':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading users...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
|
||||
<p className="mt-2 text-gray-600">Manage user accounts, roles, and permissions</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ Add New User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-2xl">👥</span>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Total Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{users.length}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-2xl">✅</span>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Active Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{users.filter(u => u.status === 'active').length}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-2xl">🔴</span>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Disabled Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{users.filter(u => u.status === 'disabled').length}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-2xl">👑</span>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Admins</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{users.filter(u => u.roles.includes('admin')).length}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Users</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Click on any field to edit user details
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Roles
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
roles={roles}
|
||||
isEditing={editingUser === user.id}
|
||||
onEdit={() => setEditingUser(user.id)}
|
||||
onCancel={() => setEditingUser(null)}
|
||||
onStatusToggle={handleStatusToggle}
|
||||
onRoleUpdate={handleRoleUpdate}
|
||||
onEmailUpdate={handleEmailUpdate}
|
||||
getRoleBadgeColor={getRoleBadgeColor}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create User Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateUserModal
|
||||
roles={roles}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onUserCreated={handleUserCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// UserRow component for inline editing
|
||||
interface UserRowProps {
|
||||
user: User
|
||||
roles: Role[]
|
||||
isEditing: boolean
|
||||
onEdit: () => void
|
||||
onCancel: () => void
|
||||
onStatusToggle: (userId: string, currentStatus: UserStatus) => void
|
||||
onRoleUpdate: (userId: string, newRoles: RoleName[]) => void
|
||||
onEmailUpdate: (userId: string, newEmail: string) => void
|
||||
getRoleBadgeColor: (role: string) => string
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
user,
|
||||
roles,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onStatusToggle,
|
||||
onRoleUpdate,
|
||||
onEmailUpdate,
|
||||
getRoleBadgeColor
|
||||
}: UserRowProps) {
|
||||
const [editEmail, setEditEmail] = useState(user.email)
|
||||
const [editRoles, setEditRoles] = useState<RoleName[]>(user.roles)
|
||||
|
||||
const handleSave = () => {
|
||||
if (editEmail !== user.email) {
|
||||
onEmailUpdate(user.id, editEmail)
|
||||
}
|
||||
if (JSON.stringify(editRoles.sort()) !== JSON.stringify(user.roles.sort())) {
|
||||
onRoleUpdate(user.id, editRoles)
|
||||
}
|
||||
if (editEmail === user.email && JSON.stringify(editRoles.sort()) === JSON.stringify(user.roles.sort())) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleToggle = (roleName: RoleName) => {
|
||||
if (editRoles.includes(roleName)) {
|
||||
setEditRoles(editRoles.filter(r => r !== roleName))
|
||||
} else {
|
||||
setEditRoles([...editRoles, roleName])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="email"
|
||||
value={editEmail}
|
||||
onChange={(e) => setEditEmail(e.target.value)}
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="text-sm text-gray-900 cursor-pointer hover:text-blue-600"
|
||||
onClick={onEdit}
|
||||
>
|
||||
{user.email}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
{roles.map((role) => (
|
||||
<label key={role.id} className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editRoles.includes(role.name)}
|
||||
onChange={() => handleRoleToggle(role.name)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">{role.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex flex-wrap gap-1 cursor-pointer"
|
||||
onClick={onEdit}
|
||||
>
|
||||
{user.roles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
|
||||
>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => onStatusToggle(user.id, user.status)}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-red-100 text-red-800 hover:bg-red-200'
|
||||
}`}
|
||||
>
|
||||
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{isEditing ? (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
963
web/src/components/VisionSystem.tsx
Normal file
963
web/src/components/VisionSystem.tsx
Normal file
@@ -0,0 +1,963 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, memo, startTransition } from 'react'
|
||||
import {
|
||||
visionApi,
|
||||
type SystemStatus,
|
||||
type CameraStatus,
|
||||
type MachineStatus,
|
||||
type StorageStats,
|
||||
type RecordingInfo,
|
||||
type MqttStatus,
|
||||
type MqttEventsResponse,
|
||||
type MqttEvent,
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
formatUptime
|
||||
} from '../lib/visionApi'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { CameraConfigModal } from './CameraConfigModal'
|
||||
import { CameraPreviewModal } from './CameraPreviewModal'
|
||||
|
||||
// Memoized components to prevent unnecessary re-renders
|
||||
const SystemOverview = memo(({ systemStatus }: { systemStatus: SystemStatus }) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.system_started ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{systemStatus.system_started ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">System Status</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Uptime: {formatUptime(systemStatus.uptime_seconds)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.mqtt_connected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{systemStatus.mqtt_connected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">MQTT Status</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Last message: {systemStatus.last_mqtt_message || 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{systemStatus.active_recordings} Active
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">Recordings</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Total: {systemStatus.total_recordings}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{Object.keys(systemStatus.cameras).length} Cameras
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">Devices</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
{Object.keys(systemStatus.machines).length} Machines
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
const StorageOverview = memo(({ storageStats }: { storageStats: StorageStats }) => (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Storage</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Storage usage and file statistics
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">{storageStats.total_files}</div>
|
||||
<div className="text-sm text-gray-500">Total Files</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{formatBytes(storageStats.total_size_bytes)}</div>
|
||||
<div className="text-sm text-gray-500">Total Size</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">{formatBytes(storageStats.disk_usage.free)}</div>
|
||||
<div className="text-sm text-gray-500">Free Space</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Usage Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>Disk Usage</span>
|
||||
<span>{Math.round((storageStats.disk_usage.used / storageStats.disk_usage.total) * 100)}% used</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${(storageStats.disk_usage.used / storageStats.disk_usage.total) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>{formatBytes(storageStats.disk_usage.used)} used</span>
|
||||
<span>{formatBytes(storageStats.disk_usage.total)} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-Camera Statistics */}
|
||||
{Object.keys(storageStats.cameras).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Files by Camera</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(storageStats.cameras).map(([cameraName, stats]) => (
|
||||
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">{cameraName}</h5>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Files:</span>
|
||||
<span className="text-gray-900">{stats.file_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Size:</span>
|
||||
<span className="text-gray-900">{formatBytes(stats.total_size_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
const CamerasStatus = memo(({
|
||||
systemStatus,
|
||||
onConfigureCamera,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onPreviewCamera,
|
||||
onStopStreaming
|
||||
}: {
|
||||
systemStatus: SystemStatus,
|
||||
onConfigureCamera: (cameraName: string) => void,
|
||||
onStartRecording: (cameraName: string) => Promise<void>,
|
||||
onStopRecording: (cameraName: string) => Promise<void>,
|
||||
onPreviewCamera: (cameraName: string) => void,
|
||||
onStopStreaming: (cameraName: string) => Promise<void>
|
||||
}) => {
|
||||
const { isAdmin } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Cameras</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Current status of all cameras in the system
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-6">
|
||||
{Object.entries(systemStatus.cameras).map(([cameraName, camera]) => {
|
||||
const friendlyName = camera.device_info?.friendly_name
|
||||
const hasDeviceInfo = !!camera.device_info
|
||||
const hasSerial = !!camera.device_info?.serial_number
|
||||
|
||||
// Determine if camera is connected based on status
|
||||
const isConnected = camera.status === 'available' || camera.status === 'connected'
|
||||
const hasError = camera.status === 'error'
|
||||
const statusText = camera.status || 'unknown'
|
||||
|
||||
return (
|
||||
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{friendlyName || cameraName}
|
||||
{friendlyName && (
|
||||
<span className="text-gray-500 text-sm font-normal ml-2">({cameraName})</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isConnected ? 'bg-green-100 text-green-800' :
|
||||
hasError ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{isConnected ? 'Connected' : hasError ? 'Error' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className={`font-medium ${isConnected ? 'text-green-600' :
|
||||
hasError ? 'text-yellow-600' :
|
||||
'text-red-600'
|
||||
}`}>
|
||||
{statusText.charAt(0).toUpperCase() + statusText.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{camera.is_recording && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Recording:</span>
|
||||
<span className="text-red-600 font-medium flex items-center">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-2 animate-pulse"></div>
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDeviceInfo && (
|
||||
<>
|
||||
{camera.device_info.model && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Model:</span>
|
||||
<span className="text-gray-900">{camera.device_info.model}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasSerial && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Serial:</span>
|
||||
<span className="text-gray-900 font-mono text-xs">{camera.device_info.serial_number}</span>
|
||||
</div>
|
||||
)}
|
||||
{camera.device_info.firmware_version && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Firmware:</span>
|
||||
<span className="text-gray-900 font-mono text-xs">{camera.device_info.firmware_version}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{camera.last_frame_time && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last Frame:</span>
|
||||
<span className="text-gray-900">{new Date(camera.last_frame_time).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.frame_rate && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Frame Rate:</span>
|
||||
<span className="text-gray-900">{camera.frame_rate.toFixed(1)} fps</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.last_checked && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last Checked:</span>
|
||||
<span className="text-gray-900">{new Date(camera.last_checked).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.current_recording_file && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Recording File:</span>
|
||||
<span className="text-gray-900 truncate ml-2">{camera.current_recording_file}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.last_error && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded">
|
||||
<div className="text-red-800 text-xs">
|
||||
<strong>Error:</strong> {camera.last_error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Camera Control Buttons */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 space-y-2">
|
||||
{/* Recording Controls */}
|
||||
<div className="flex space-x-2">
|
||||
{!camera.is_recording ? (
|
||||
<button
|
||||
onClick={() => onStartRecording(cameraName)}
|
||||
disabled={!isConnected}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
|
||||
? 'text-green-600 bg-green-50 border border-green-200 hover:bg-green-100 focus:ring-green-500'
|
||||
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h8m-9-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Start Recording
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onStopRecording(cameraName)}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-red-600 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
>
|
||||
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9h6v6H9z" />
|
||||
</svg>
|
||||
Stop Recording
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview and Streaming Controls */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => onPreviewCamera(cameraName)}
|
||||
disabled={!isConnected}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
|
||||
? 'text-blue-600 bg-blue-50 border border-blue-200 hover:bg-blue-100 focus:ring-blue-500'
|
||||
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Preview
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onStopStreaming(cameraName)}
|
||||
disabled={!isConnected}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
|
||||
? 'text-orange-600 bg-orange-50 border border-orange-200 hover:bg-orange-100 focus:ring-orange-500'
|
||||
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Stop Streaming
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Configuration Button */}
|
||||
{isAdmin() && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => onConfigureCamera(cameraName)}
|
||||
className="w-full px-3 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Configure Camera
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const RecentRecordings = memo(({ recordings, systemStatus }: { recordings: Record<string, RecordingInfo>, systemStatus: SystemStatus | null }) => (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Recent Recordings</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Latest recording sessions
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Camera
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Filename
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Started
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{Object.entries(recordings).slice(0, 10).map(([recordingId, recording]) => {
|
||||
const camera = systemStatus?.cameras[recording.camera_name]
|
||||
const displayName = camera?.device_info?.friendly_name || recording.camera_name
|
||||
|
||||
return (
|
||||
<tr key={recordingId}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{displayName}
|
||||
{camera?.device_info?.friendly_name && (
|
||||
<div className="text-xs text-gray-500">({recording.camera_name})</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-mono">
|
||||
{recording.filename}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${recording.status === 'recording' ? 'bg-red-100 text-red-800' :
|
||||
recording.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{recording.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{recording.duration_seconds ? formatDuration(recording.duration_seconds) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{recording.file_size_bytes ? formatBytes(recording.file_size_bytes) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(recording.start_time).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
export function VisionSystem() {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
|
||||
const [storageStats, setStorageStats] = useState<StorageStats | null>(null)
|
||||
const [recordings, setRecordings] = useState<Record<string, RecordingInfo>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
|
||||
const [refreshInterval, setRefreshInterval] = useState(5000) // 5 seconds default
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null)
|
||||
const [mqttStatus, setMqttStatus] = useState<MqttStatus | null>(null)
|
||||
const [mqttEvents, setMqttEvents] = useState<MqttEvent[]>([])
|
||||
|
||||
// Camera configuration modal state
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false)
|
||||
const [selectedCamera, setSelectedCamera] = useState<string | null>(null)
|
||||
const [notification, setNotification] = useState<{ type: 'success' | 'error', message: string } | null>(null)
|
||||
|
||||
// Camera preview modal state
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
const [previewCamera, setPreviewCamera] = useState<string | null>(null)
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const clearAutoRefresh = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startAutoRefresh = useCallback(() => {
|
||||
clearAutoRefresh()
|
||||
if (autoRefreshEnabled && refreshInterval > 0) {
|
||||
intervalRef.current = setInterval(fetchData, refreshInterval)
|
||||
}
|
||||
}, [autoRefreshEnabled, refreshInterval])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
startAutoRefresh()
|
||||
return clearAutoRefresh
|
||||
}, [startAutoRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
startAutoRefresh()
|
||||
}, [autoRefreshEnabled, refreshInterval, startAutoRefresh])
|
||||
|
||||
const fetchData = useCallback(async (showRefreshIndicator = true) => {
|
||||
try {
|
||||
setError(null)
|
||||
if (!systemStatus) {
|
||||
setLoading(true)
|
||||
} else if (showRefreshIndicator) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
const [statusData, storageData, recordingsData, mqttStatusData, mqttEventsData] = await Promise.all([
|
||||
visionApi.getSystemStatus(),
|
||||
visionApi.getStorageStats(),
|
||||
visionApi.getRecordings(),
|
||||
visionApi.getMqttStatus().catch(err => {
|
||||
console.warn('Failed to fetch MQTT status:', err)
|
||||
return null
|
||||
}),
|
||||
visionApi.getMqttEvents(10).catch(err => {
|
||||
console.warn('Failed to fetch MQTT events:', err)
|
||||
return { events: [], total_events: 0, last_updated: '' }
|
||||
})
|
||||
])
|
||||
|
||||
// If cameras don't have device_info, try to fetch individual camera status
|
||||
if (statusData.cameras) {
|
||||
const camerasNeedingInfo = Object.entries(statusData.cameras)
|
||||
.filter(([_, camera]) => !camera.device_info?.friendly_name)
|
||||
.map(([cameraName, _]) => cameraName)
|
||||
|
||||
if (camerasNeedingInfo.length > 0) {
|
||||
console.log('Fetching individual camera info for:', camerasNeedingInfo)
|
||||
try {
|
||||
const individualCameraData = await Promise.all(
|
||||
camerasNeedingInfo.map(cameraName =>
|
||||
visionApi.getCameraStatus(cameraName).catch(err => {
|
||||
console.warn(`Failed to get individual status for ${cameraName}:`, err)
|
||||
return null
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Merge the individual camera data back into statusData
|
||||
camerasNeedingInfo.forEach((cameraName, index) => {
|
||||
const individualData = individualCameraData[index]
|
||||
if (individualData && individualData.device_info) {
|
||||
statusData.cameras[cameraName] = {
|
||||
...statusData.cameras[cameraName],
|
||||
device_info: individualData.device_info
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch individual camera data:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch state updates to minimize re-renders using startTransition for non-urgent updates
|
||||
const updateTime = new Date()
|
||||
|
||||
// Use startTransition for non-urgent state updates to keep the UI responsive
|
||||
startTransition(() => {
|
||||
setSystemStatus(statusData)
|
||||
setStorageStats(storageData)
|
||||
setRecordings(recordingsData)
|
||||
setLastUpdateTime(updateTime)
|
||||
|
||||
// Update MQTT status and events
|
||||
if (mqttStatusData) {
|
||||
setMqttStatus(mqttStatusData)
|
||||
}
|
||||
|
||||
if (mqttEventsData && mqttEventsData.events) {
|
||||
setMqttEvents(mqttEventsData.events)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch vision system data')
|
||||
console.error('Vision system fetch error:', err)
|
||||
// Don't disable auto-refresh on errors, just log them
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [systemStatus])
|
||||
|
||||
// Camera configuration handlers
|
||||
const handleConfigureCamera = (cameraName: string) => {
|
||||
setSelectedCamera(cameraName)
|
||||
setConfigModalOpen(true)
|
||||
}
|
||||
|
||||
const handleConfigSuccess = (message: string) => {
|
||||
setNotification({ type: 'success', message })
|
||||
setTimeout(() => setNotification(null), 5000)
|
||||
}
|
||||
|
||||
const handleConfigError = (message: string) => {
|
||||
setNotification({ type: 'error', message })
|
||||
setTimeout(() => setNotification(null), 5000)
|
||||
}
|
||||
|
||||
// Recording control handlers
|
||||
const handleStartRecording = async (cameraName: string) => {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `manual_${cameraName}_${timestamp}.mp4`
|
||||
|
||||
const result = await visionApi.startRecording(cameraName, { filename })
|
||||
|
||||
if (result.success) {
|
||||
setNotification({ type: 'success', message: `Recording started: ${result.filename}` })
|
||||
// Refresh data to update recording status
|
||||
fetchData(false)
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed to start recording: ${result.message}` })
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setNotification({ type: 'error', message: `Error starting recording: ${errorMessage}` })
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRecording = async (cameraName: string) => {
|
||||
try {
|
||||
const result = await visionApi.stopRecording(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
setNotification({ type: 'success', message: `Recording stopped: ${result.filename}` })
|
||||
// Refresh data to update recording status
|
||||
fetchData(false)
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed to stop recording: ${result.message}` })
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setNotification({ type: 'error', message: `Error stopping recording: ${errorMessage}` })
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewCamera = (cameraName: string) => {
|
||||
setPreviewCamera(cameraName)
|
||||
setPreviewModalOpen(true)
|
||||
}
|
||||
|
||||
const handleStopStreaming = async (cameraName: string) => {
|
||||
try {
|
||||
const result = await visionApi.stopStream(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
setNotification({ type: 'success', message: `Streaming stopped for ${cameraName}` })
|
||||
// Refresh data to update camera status
|
||||
fetchData(false)
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed to stop streaming: ${result.message}` })
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setNotification({ type: 'error', message: `Error stopping stream: ${errorMessage}` })
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string, isRecording: boolean = false) => {
|
||||
// If camera is recording, always show red regardless of status
|
||||
if (isRecording) {
|
||||
return 'text-red-600 bg-red-100'
|
||||
}
|
||||
|
||||
switch (status.toLowerCase()) {
|
||||
case 'available':
|
||||
case 'connected':
|
||||
case 'healthy':
|
||||
case 'on':
|
||||
return 'text-green-600 bg-green-100'
|
||||
case 'disconnected':
|
||||
case 'off':
|
||||
case 'failed':
|
||||
return 'text-red-600 bg-red-100'
|
||||
case 'error':
|
||||
case 'warning':
|
||||
case 'degraded':
|
||||
return 'text-yellow-600 bg-yellow-100'
|
||||
default:
|
||||
return 'text-yellow-600 bg-yellow-100'
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineStateColor = (state: string) => {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'on':
|
||||
case 'running':
|
||||
return 'text-green-600 bg-green-100'
|
||||
case 'off':
|
||||
case 'stopped':
|
||||
return 'text-gray-600 bg-gray-100'
|
||||
default:
|
||||
return 'text-yellow-600 bg-yellow-100'
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading vision system data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error loading vision system</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => fetchData(true)}
|
||||
disabled={refreshing}
|
||||
className="bg-red-100 px-3 py-2 rounded-md text-sm font-medium text-red-800 hover:bg-red-200 disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? 'Retrying...' : 'Try Again'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Vision System</h1>
|
||||
<p className="mt-2 text-gray-600">Monitor cameras, machines, and recording status</p>
|
||||
{lastUpdateTime && (
|
||||
<p className={`mt-1 text-sm text-gray-500 flex items-center space-x-2 ${refreshing ? 'animate-pulse' : ''}`}>
|
||||
<span>Last updated: {lastUpdateTime.toLocaleTimeString()}</span>
|
||||
{refreshing && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<span className="animate-spin rounded-full h-3 w-3 border-b border-blue-600 mr-1 inline-block"></span>
|
||||
Updating...
|
||||
</span>
|
||||
)}
|
||||
{autoRefreshEnabled && !refreshing && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Auto-refresh: {refreshInterval / 1000}s
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Auto-refresh controls */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefreshEnabled}
|
||||
onChange={(e) => setAutoRefreshEnabled(e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Auto-refresh</span>
|
||||
</label>
|
||||
{autoRefreshEnabled && (
|
||||
<select
|
||||
value={refreshInterval}
|
||||
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||||
className="text-sm border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value={2000}>2s</option>
|
||||
<option value={5000}>5s</option>
|
||||
<option value={10000}>10s</option>
|
||||
<option value={30000}>30s</option>
|
||||
<option value={60000}>1m</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refresh indicator and button */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{refreshing && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600"></div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => fetchData(true)}
|
||||
disabled={refreshing}
|
||||
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Overview */}
|
||||
{systemStatus && <SystemOverview systemStatus={systemStatus} />}
|
||||
|
||||
|
||||
|
||||
{/* Cameras Status */}
|
||||
{systemStatus && (
|
||||
<CamerasStatus
|
||||
systemStatus={systemStatus}
|
||||
onConfigureCamera={handleConfigureCamera}
|
||||
onStartRecording={handleStartRecording}
|
||||
onStopRecording={handleStopRecording}
|
||||
onPreviewCamera={handlePreviewCamera}
|
||||
onStopStreaming={handleStopStreaming}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Machines Status */}
|
||||
{systemStatus && Object.keys(systemStatus.machines).length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Machines</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Current status of all machines in the system
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-6">
|
||||
{Object.entries(systemStatus.machines).map(([machineName, machine]) => (
|
||||
<div key={machineName} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-gray-900 capitalize">
|
||||
{machineName.replace(/_/g, ' ')}
|
||||
</h4>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMachineStateColor(machine.state)}`}>
|
||||
{machine.state}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last updated:</span>
|
||||
<span className="text-gray-900">{new Date(machine.last_updated).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
|
||||
{machine.last_message && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last message:</span>
|
||||
<span className="text-gray-900">{machine.last_message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{machine.mqtt_topic && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">MQTT topic:</span>
|
||||
<span className="text-gray-900 text-xs font-mono">{machine.mqtt_topic}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Storage Statistics */}
|
||||
{storageStats && <StorageOverview storageStats={storageStats} />}
|
||||
|
||||
{/* Recent Recordings */}
|
||||
{Object.keys(recordings).length > 0 && <RecentRecordings recordings={recordings} systemStatus={systemStatus} />}
|
||||
|
||||
{/* Camera Configuration Modal */}
|
||||
{selectedCamera && (
|
||||
<CameraConfigModal
|
||||
cameraName={selectedCamera}
|
||||
isOpen={configModalOpen}
|
||||
onClose={() => {
|
||||
setConfigModalOpen(false)
|
||||
setSelectedCamera(null)
|
||||
}}
|
||||
onSuccess={handleConfigSuccess}
|
||||
onError={handleConfigError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Camera Preview Modal */}
|
||||
{previewCamera && (
|
||||
<CameraPreviewModal
|
||||
cameraName={previewCamera}
|
||||
isOpen={previewModalOpen}
|
||||
onClose={() => {
|
||||
setPreviewModalOpen(false)
|
||||
setPreviewCamera(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notification */}
|
||||
{notification && (
|
||||
<div className={`fixed top-4 right-4 z-50 p-4 rounded-md shadow-lg ${notification.type === 'success'
|
||||
? 'bg-green-50 border border-green-200 text-green-800'
|
||||
: 'bg-red-50 border border-red-200 text-red-800'
|
||||
}`}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
{notification.type === 'success' ? (
|
||||
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium">{notification.message}</p>
|
||||
</div>
|
||||
<div className="ml-auto pl-3">
|
||||
<button
|
||||
onClick={() => setNotification(null)}
|
||||
className={`inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2 ${notification.type === 'success'
|
||||
? 'text-green-500 hover:bg-green-100 focus:ring-green-600'
|
||||
: 'text-red-500 hover:bg-red-100 focus:ring-red-600'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
200
web/src/features/video-streaming/VideoStreamingPage.tsx
Normal file
200
web/src/features/video-streaming/VideoStreamingPage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* VideoStreamingPage Component
|
||||
*
|
||||
* Main page component for the video streaming feature.
|
||||
* Demonstrates how to compose the modular components together.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { VideoList, VideoModal, ApiStatusIndicator, VideoErrorBoundary, PerformanceDashboard } from './components';
|
||||
import { type VideoFile, type VideoListFilters, type VideoListSortOptions } from './types';
|
||||
|
||||
export const VideoStreamingPage: React.FC = () => {
|
||||
const [selectedVideo, setSelectedVideo] = useState<VideoFile | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<VideoListFilters>({});
|
||||
const [sortOptions, setSortOptions] = useState<VideoListSortOptions>({
|
||||
field: 'created_at',
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
// Available cameras for filtering (this could come from an API)
|
||||
const availableCameras = ['camera1', 'camera2', 'camera3']; // This should be fetched from your camera API
|
||||
|
||||
const handleVideoSelect = (video: VideoFile) => {
|
||||
setSelectedVideo(video);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedVideo(null);
|
||||
};
|
||||
|
||||
const handleCameraFilterChange = (cameraName: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
cameraName: cameraName === 'all' ? undefined : cameraName,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSortChange = (field: VideoListSortOptions['field'], direction: VideoListSortOptions['direction']) => {
|
||||
setSortOptions({ field, direction });
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (start: string, end: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
dateRange: start && end ? { start, end } : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoErrorBoundary>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Video Library</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Browse and view recorded videos from your camera system
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<ApiStatusIndicator showDetails={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Controls */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-theme-sm">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Camera Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Camera
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={filters.cameraName || 'all'}
|
||||
onChange={(e) => handleCameraFilterChange(e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
>
|
||||
<option value="all">All Cameras</option>
|
||||
{availableCameras.map(camera => (
|
||||
<option key={camera} value={camera}>
|
||||
{camera}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort Options */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sort by
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<select
|
||||
value={sortOptions.field}
|
||||
onChange={(e) => handleSortChange(e.target.value as VideoListSortOptions['field'], sortOptions.direction)}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
>
|
||||
<option value="created_at">Date Created</option>
|
||||
<option value="file_size_bytes">File Size</option>
|
||||
<option value="camera_name">Camera Name</option>
|
||||
<option value="filename">Filename</option>
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSortChange(sortOptions.field, sortOptions.direction === 'asc' ? 'desc' : 'asc')}
|
||||
className="px-3 py-2.5 border border-gray-300 rounded-lg bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
title={`Sort ${sortOptions.direction === 'asc' ? 'Descending' : 'Ascending'}`}
|
||||
>
|
||||
{sortOptions.direction === 'asc' ? (
|
||||
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h9m5-4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateRange?.start || ''}
|
||||
onChange={(e) => handleDateRangeChange(e.target.value, filters.dateRange?.end || '')}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateRange?.end || ''}
|
||||
onChange={(e) => handleDateRangeChange(filters.dateRange?.start || '', e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(filters.cameraName || filters.dateRange) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setFilters({})}
|
||||
className="inline-flex items-center px-4 py-2.5 text-sm font-medium transition rounded-lg border bg-white text-gray-700 border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video List */}
|
||||
<VideoList
|
||||
filters={filters}
|
||||
sortOptions={sortOptions}
|
||||
onVideoSelect={handleVideoSelect}
|
||||
limit={24}
|
||||
/>
|
||||
|
||||
{/* Video Modal */}
|
||||
<VideoModal
|
||||
video={selectedVideo}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
|
||||
{/* Performance Dashboard (development only) */}
|
||||
<PerformanceDashboard />
|
||||
</div>
|
||||
</VideoErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* ApiStatusIndicator Component
|
||||
*
|
||||
* A component that displays the connection status of the video streaming API
|
||||
* and provides helpful information when the API is not accessible.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
|
||||
interface ApiStatusIndicatorProps {
|
||||
className?: string;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export const ApiStatusIndicator: React.FC<ApiStatusIndicatorProps> = ({
|
||||
className = '',
|
||||
showDetails = false,
|
||||
}) => {
|
||||
const [isOnline, setIsOnline] = useState<boolean | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [lastChecked, setLastChecked] = useState<Date | null>(null);
|
||||
|
||||
const checkApiStatus = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const status = await videoApiService.healthCheck();
|
||||
setIsOnline(status);
|
||||
setLastChecked(new Date());
|
||||
} catch (error) {
|
||||
setIsOnline(false);
|
||||
setLastChecked(new Date());
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkApiStatus();
|
||||
|
||||
// Check status every 30 seconds
|
||||
const interval = setInterval(checkApiStatus, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (isChecking) return 'bg-yellow-500';
|
||||
if (isOnline === null) return 'bg-gray-500';
|
||||
return isOnline ? 'bg-green-500' : 'bg-red-500';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isChecking) return 'Checking...';
|
||||
if (isOnline === null) return 'Unknown';
|
||||
return isOnline ? 'Connected' : 'Disconnected';
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white"></div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOnline) {
|
||||
return (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
if (!showDetails) {
|
||||
return (
|
||||
<div className={`inline-flex items-center ${className}`}>
|
||||
<div className={`w-2 h-2 rounded-full ${getStatusColor()} mr-2`}></div>
|
||||
<span className="text-sm text-gray-600">{getStatusText()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white border border-gray-200 rounded-lg p-4 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-900">Video API Status</h3>
|
||||
<button
|
||||
onClick={checkApiStatus}
|
||||
disabled={isChecking}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-2">
|
||||
<div className={`w-3 h-3 rounded-full ${getStatusColor()} mr-2 flex items-center justify-center text-white`}>
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{getStatusText()}</span>
|
||||
</div>
|
||||
|
||||
{lastChecked && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Last checked: {lastChecked.toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOnline === false && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="text-sm text-red-800">
|
||||
<strong>Connection Failed</strong>
|
||||
<p className="mt-1">
|
||||
Cannot connect to the USDA Vision Camera System. Please ensure:
|
||||
</p>
|
||||
<ul className="mt-2 list-disc list-inside space-y-1">
|
||||
<li>The vision system is running</li>
|
||||
<li>The API is accessible at the configured URL</li>
|
||||
<li>Network connectivity is available</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
160
web/src/features/video-streaming/components/Pagination.tsx
Normal file
160
web/src/features/video-streaming/components/Pagination.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Pagination Component
|
||||
*
|
||||
* A reusable pagination component that matches the dashboard template's styling patterns.
|
||||
* Provides page navigation with first/last, previous/next, and numbered page buttons.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { type PaginationProps } from '../types';
|
||||
|
||||
export const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
showFirstLast = true,
|
||||
showPrevNext = true,
|
||||
maxVisiblePages = 5,
|
||||
className = '',
|
||||
}) => {
|
||||
// Don't render if there's only one page or no pages
|
||||
if (totalPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate visible page numbers
|
||||
const getVisiblePages = (): number[] => {
|
||||
const pages: number[] = [];
|
||||
const halfVisible = Math.floor(maxVisiblePages / 2);
|
||||
|
||||
let startPage = Math.max(1, currentPage - halfVisible);
|
||||
let endPage = Math.min(totalPages, currentPage + halfVisible);
|
||||
|
||||
// Adjust if we're near the beginning or end
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
if (startPage === 1) {
|
||||
endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
||||
} else {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const visiblePages = getVisiblePages();
|
||||
const isFirstPage = currentPage === 1;
|
||||
const isLastPage = currentPage === totalPages;
|
||||
|
||||
// Button base classes matching dashboard template
|
||||
const baseButtonClasses = "inline-flex items-center justify-center px-3 py-2 text-sm font-medium transition-all duration-200 rounded-lg border";
|
||||
|
||||
// Active page button classes
|
||||
const activeButtonClasses = "bg-brand-500 text-white border-brand-500 hover:bg-brand-600 shadow-theme-sm";
|
||||
|
||||
// Inactive page button classes
|
||||
const inactiveButtonClasses = "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:border-gray-400 shadow-theme-xs";
|
||||
|
||||
// Disabled button classes
|
||||
const disabledButtonClasses = "bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed opacity-50";
|
||||
|
||||
const handlePageClick = (page: number) => {
|
||||
if (page !== currentPage && page >= 1 && page <= totalPages) {
|
||||
onPageChange(page);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center space-x-1 ${className}`}>
|
||||
{/* First Page Button */}
|
||||
{showFirstLast && !isFirstPage && (
|
||||
<button
|
||||
onClick={() => handlePageClick(1)}
|
||||
className={`${baseButtonClasses} ${inactiveButtonClasses}`}
|
||||
aria-label="Go to first page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Previous Page Button */}
|
||||
{showPrevNext && (
|
||||
<button
|
||||
onClick={() => handlePageClick(currentPage - 1)}
|
||||
disabled={isFirstPage}
|
||||
className={`${baseButtonClasses} ${isFirstPage ? disabledButtonClasses : inactiveButtonClasses}`}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Page Number Buttons */}
|
||||
{visiblePages.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageClick(page)}
|
||||
className={`${baseButtonClasses} ${page === currentPage ? activeButtonClasses : inactiveButtonClasses
|
||||
} min-w-[40px]`}
|
||||
aria-label={`Go to page ${page}`}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Next Page Button */}
|
||||
{showPrevNext && (
|
||||
<button
|
||||
onClick={() => handlePageClick(currentPage + 1)}
|
||||
disabled={isLastPage}
|
||||
className={`${baseButtonClasses} ${isLastPage ? disabledButtonClasses : inactiveButtonClasses}`}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Last Page Button */}
|
||||
{showFirstLast && !isLastPage && (
|
||||
<button
|
||||
onClick={() => handlePageClick(totalPages)}
|
||||
className={`${baseButtonClasses} ${inactiveButtonClasses}`}
|
||||
aria-label="Go to last page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Page info component to show current page and total
|
||||
export const PageInfo: React.FC<{
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
className?: string;
|
||||
}> = ({ currentPage, totalPages, totalItems, itemsPerPage, className = '' }) => {
|
||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
return (
|
||||
<div className={`text-sm text-gray-600 ${className}`}>
|
||||
Showing {startItem} to {endItem} of {totalItems} results (Page {currentPage} of {totalPages})
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* PerformanceDashboard Component
|
||||
*
|
||||
* A development tool for monitoring video streaming performance.
|
||||
* Only shown in development mode.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { performanceMonitor, thumbnailCache } from '../utils';
|
||||
|
||||
interface PerformanceDashboardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PerformanceDashboard: React.FC<PerformanceDashboardProps> = ({
|
||||
className = '',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [cacheStats, setCacheStats] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const updateStats = () => {
|
||||
setStats({
|
||||
overall: performanceMonitor.getStats(),
|
||||
getVideos: performanceMonitor.getStats('get_videos'),
|
||||
getThumbnail: performanceMonitor.getStats('get_thumbnail'),
|
||||
recentMetrics: performanceMonitor.getRecentMetrics(5),
|
||||
});
|
||||
setCacheStats(thumbnailCache.getStats());
|
||||
};
|
||||
|
||||
updateStats();
|
||||
const interval = setInterval(updateStats, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Only show in development
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`fixed bottom-4 right-4 bg-blue-600 text-white p-2 rounded-full shadow-lg hover:bg-blue-700 transition-colors z-50 ${className}`}
|
||||
title="Open Performance Dashboard"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-4 right-4 bg-white border border-gray-200 rounded-lg shadow-xl p-4 max-w-md w-80 max-h-96 overflow-y-auto z-50 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Performance</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="space-y-4">
|
||||
{/* Overall Stats */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Overall</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Operations: {stats.overall.totalOperations}</div>
|
||||
<div>Success: {(stats.overall.successRate * 100).toFixed(1)}%</div>
|
||||
<div>Avg: {stats.overall.averageDuration.toFixed(0)}ms</div>
|
||||
<div>Max: {stats.overall.maxDuration.toFixed(0)}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Loading Stats */}
|
||||
{stats.getVideos.totalOperations > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Video Loading</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Calls: {stats.getVideos.totalOperations}</div>
|
||||
<div>Success: {(stats.getVideos.successRate * 100).toFixed(1)}%</div>
|
||||
<div>Avg: {stats.getVideos.averageDuration.toFixed(0)}ms</div>
|
||||
<div>Max: {stats.getVideos.maxDuration.toFixed(0)}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Stats */}
|
||||
{stats.getThumbnail.totalOperations > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Thumbnails</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Calls: {stats.getThumbnail.totalOperations}</div>
|
||||
<div>Success: {(stats.getThumbnail.successRate * 100).toFixed(1)}%</div>
|
||||
<div>Avg: {stats.getThumbnail.averageDuration.toFixed(0)}ms</div>
|
||||
<div>Max: {stats.getThumbnail.maxDuration.toFixed(0)}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cache Stats */}
|
||||
{cacheStats && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Cache</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Cached: {cacheStats.size}</div>
|
||||
<div>Memory: {(cacheStats.totalMemory / 1024 / 1024).toFixed(1)}MB</div>
|
||||
<div>Hits: {cacheStats.totalAccess}</div>
|
||||
<div>Avg Size: {(cacheStats.averageSize / 1024).toFixed(0)}KB</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Operations */}
|
||||
{stats.recentMetrics.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Recent</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.recentMetrics.map((metric: any, index: number) => (
|
||||
<div key={index} className="flex justify-between text-xs">
|
||||
<span className={metric.success ? 'text-green-600' : 'text-red-600'}>
|
||||
{metric.operation}
|
||||
</span>
|
||||
<span>{metric.duration?.toFixed(0)}ms</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
performanceMonitor.clear();
|
||||
thumbnailCache.clear();
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log(performanceMonitor.getReport());
|
||||
console.log('Cache Stats:', thumbnailCache.getStats());
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
Log Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
171
web/src/features/video-streaming/components/VideoCard.tsx
Normal file
171
web/src/features/video-streaming/components/VideoCard.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* VideoCard Component
|
||||
*
|
||||
* A reusable card component for displaying video information with thumbnail, metadata, and actions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { type VideoCardProps } from '../types';
|
||||
import { VideoThumbnail } from './VideoThumbnail';
|
||||
import {
|
||||
formatFileSize,
|
||||
formatVideoDate,
|
||||
getRelativeTime,
|
||||
getFormatDisplayName,
|
||||
getStatusBadgeClass,
|
||||
getResolutionString,
|
||||
} from '../utils/videoUtils';
|
||||
|
||||
export const VideoCard: React.FC<VideoCardProps> = ({
|
||||
video,
|
||||
onClick,
|
||||
showMetadata = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick(video);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = () => {
|
||||
handleClick();
|
||||
};
|
||||
|
||||
const cardClasses = [
|
||||
'bg-white rounded-xl border border-gray-200 overflow-hidden transition-all hover:shadow-theme-md',
|
||||
onClick ? 'cursor-pointer hover:border-gray-300' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={cardClasses} onClick={onClick ? handleClick : undefined}>
|
||||
{/* Thumbnail */}
|
||||
<div className="relative">
|
||||
<VideoThumbnail
|
||||
fileId={video.file_id}
|
||||
width={320}
|
||||
height={180}
|
||||
alt={`Thumbnail for ${video.filename}`}
|
||||
onClick={onClick ? handleThumbnailClick : undefined}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="absolute top-2 left-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClass(video.status)}`}>
|
||||
{video.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Format Badge */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{getFormatDisplayName(video.format)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Streamable Indicator */}
|
||||
{video.is_streamable ? (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<div className="bg-green-500 text-white text-xs px-2 py-1 rounded flex items-center">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Streamable
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<div className="bg-yellow-500 text-white text-xs px-2 py-1 rounded flex items-center">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Processing
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversion Needed Indicator */}
|
||||
{video.needs_conversion && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<div className="bg-yellow-500 text-white text-xs px-2 py-1 rounded flex items-center">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Needs Conversion
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2 truncate" title={video.filename}>
|
||||
{video.filename}
|
||||
</h3>
|
||||
|
||||
{/* Camera Name */}
|
||||
<div className="flex items-center text-sm text-gray-600 mb-2">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{video.camera_name}
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600 mb-3">
|
||||
<div>
|
||||
<span className="font-medium">Size:</span> {formatFileSize(video.file_size_bytes)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Created:</span> {getRelativeTime(video.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata (if available and requested) */}
|
||||
{showMetadata && 'metadata' in video && video.metadata && (
|
||||
<div className="border-t pt-3 mt-3 border-gray-100">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">Duration:</span> {Math.round(video.metadata.duration_seconds)}s
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Resolution:</span> {getResolutionString(video.metadata.width, video.metadata.height)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">FPS:</span> {video.metadata.fps}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Codec:</span> {video.metadata.codec}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between items-center mt-4 pt-3 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatVideoDate(video.created_at)}
|
||||
</div>
|
||||
|
||||
{onClick && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium transition rounded-lg border border-transparent bg-brand-500 text-white hover:bg-brand-600 shadow-theme-xs"
|
||||
>
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Play
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
196
web/src/features/video-streaming/components/VideoDebugger.tsx
Normal file
196
web/src/features/video-streaming/components/VideoDebugger.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* VideoDebugger Component
|
||||
*
|
||||
* A development tool for debugging video streaming issues.
|
||||
* Provides direct access to test video URLs and diagnose problems.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
|
||||
interface VideoDebuggerProps {
|
||||
fileId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const VideoDebugger: React.FC<VideoDebuggerProps> = ({
|
||||
fileId,
|
||||
className = '',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [testResults, setTestResults] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const streamingUrl = videoApiService.getStreamingUrl(fileId);
|
||||
const thumbnailUrl = videoApiService.getThumbnailUrl(fileId);
|
||||
|
||||
const runDiagnostics = async () => {
|
||||
setIsLoading(true);
|
||||
const results: any = {
|
||||
timestamp: new Date().toISOString(),
|
||||
fileId,
|
||||
streamingUrl,
|
||||
thumbnailUrl,
|
||||
tests: {}
|
||||
};
|
||||
|
||||
try {
|
||||
// Test 1: Video Info
|
||||
try {
|
||||
const videoInfo = await videoApiService.getVideoInfo(fileId);
|
||||
results.tests.videoInfo = { success: true, data: videoInfo };
|
||||
} catch (error) {
|
||||
results.tests.videoInfo = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 2: Streaming Info
|
||||
try {
|
||||
const streamingInfo = await videoApiService.getStreamingInfo(fileId);
|
||||
results.tests.streamingInfo = { success: true, data: streamingInfo };
|
||||
} catch (error) {
|
||||
results.tests.streamingInfo = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 3: HEAD request to streaming URL
|
||||
try {
|
||||
const response = await fetch(streamingUrl, { method: 'HEAD' });
|
||||
results.tests.streamingHead = {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries())
|
||||
};
|
||||
} catch (error) {
|
||||
results.tests.streamingHead = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 4: Range request test
|
||||
try {
|
||||
const response = await fetch(streamingUrl, {
|
||||
headers: { 'Range': 'bytes=0-1023' }
|
||||
});
|
||||
results.tests.rangeRequest = {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
supportsRanges: response.headers.get('accept-ranges') === 'bytes',
|
||||
contentRange: response.headers.get('content-range')
|
||||
};
|
||||
} catch (error) {
|
||||
results.tests.rangeRequest = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 5: Thumbnail test
|
||||
try {
|
||||
const response = await fetch(thumbnailUrl, { method: 'HEAD' });
|
||||
results.tests.thumbnail = {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type')
|
||||
};
|
||||
} catch (error) {
|
||||
results.tests.thumbnail = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
results.error = error instanceof Error ? error.message : 'Unknown error';
|
||||
}
|
||||
|
||||
setTestResults(results);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Only show in development
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`px-3 py-1 text-xs bg-yellow-100 text-yellow-800 rounded hover:bg-yellow-200 transition-colors ${className}`}
|
||||
title="Open Video Debugger"
|
||||
>
|
||||
🔧 Debug
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-2xl ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Video Debugger</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Basic Info */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Basic Info</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<div><strong>File ID:</strong> {fileId}</div>
|
||||
<div><strong>Streaming URL:</strong> <a href={streamingUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{streamingUrl}</a></div>
|
||||
<div><strong>Thumbnail URL:</strong> <a href={thumbnailUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{thumbnailUrl}</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Quick Actions</h4>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={runDiagnostics}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Running...' : 'Run Diagnostics'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(streamingUrl, '_blank')}
|
||||
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Open Video
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(thumbnailUrl, '_blank')}
|
||||
className="px-3 py-1 text-sm bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Open Thumbnail
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{testResults && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Diagnostic Results</h4>
|
||||
<div className="bg-gray-50 rounded p-3 text-xs font-mono max-h-64 overflow-y-auto">
|
||||
<pre>{JSON.stringify(testResults, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Native Video Test */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Native Video Test</h4>
|
||||
<video
|
||||
controls
|
||||
width="100%"
|
||||
height="200"
|
||||
className="border rounded"
|
||||
onLoadStart={() => console.log('Native video load started')}
|
||||
onLoadedData={() => console.log('Native video data loaded')}
|
||||
onError={(e) => console.error('Native video error:', e)}
|
||||
>
|
||||
<source src={streamingUrl} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* VideoErrorBoundary Component
|
||||
*
|
||||
* Error boundary specifically designed for video streaming components.
|
||||
* Provides user-friendly error messages and recovery options.
|
||||
*/
|
||||
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class VideoErrorBoundary 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: React.ErrorInfo) {
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
// Call the onError callback if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// Log error for debugging
|
||||
console.error('Video streaming error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Use custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="text-center max-w-md mx-auto p-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Video System Error
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-4">
|
||||
Something went wrong with the video streaming component. This might be due to:
|
||||
</p>
|
||||
|
||||
<ul className="text-sm text-gray-500 text-left mb-6 space-y-1">
|
||||
<li>• Network connectivity issues</li>
|
||||
<li>• Video API server problems</li>
|
||||
<li>• Corrupted video files</li>
|
||||
<li>• Browser compatibility issues</li>
|
||||
</ul>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error details for debugging (only in development) */}
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Show Error Details
|
||||
</summary>
|
||||
<div className="mt-2 p-3 bg-gray-100 rounded text-xs font-mono text-gray-700 overflow-auto max-h-32">
|
||||
<div className="font-semibold mb-1">Error:</div>
|
||||
<div className="mb-2">{this.state.error.message}</div>
|
||||
<div className="font-semibold mb-1">Stack:</div>
|
||||
<div>{this.state.error.stack}</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Higher-order component for easier usage
|
||||
export function withVideoErrorBoundary<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
fallback?: ReactNode
|
||||
) {
|
||||
return function WrappedComponent(props: P) {
|
||||
return (
|
||||
<VideoErrorBoundary fallback={fallback}>
|
||||
<Component {...props} />
|
||||
</VideoErrorBoundary>
|
||||
);
|
||||
};
|
||||
}
|
||||
231
web/src/features/video-streaming/components/VideoList.tsx
Normal file
231
web/src/features/video-streaming/components/VideoList.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* VideoList Component
|
||||
*
|
||||
* A reusable component for displaying a list/grid of videos with filtering, sorting, and pagination.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { type VideoListProps, type VideoListFilters, type VideoListSortOptions } from '../types';
|
||||
import { useVideoList } from '../hooks/useVideoList';
|
||||
import { VideoCard } from './VideoCard';
|
||||
import { Pagination, PageInfo } from './Pagination';
|
||||
|
||||
export const VideoList: React.FC<VideoListProps> = ({
|
||||
filters,
|
||||
sortOptions,
|
||||
limit = 20,
|
||||
onVideoSelect,
|
||||
className = '',
|
||||
}) => {
|
||||
const [localFilters, setLocalFilters] = useState<VideoListFilters>(filters || {});
|
||||
const [localSort, setLocalSort] = useState<VideoListSortOptions>(
|
||||
sortOptions || { field: 'created_at', direction: 'desc' }
|
||||
);
|
||||
|
||||
const {
|
||||
videos,
|
||||
totalCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
loadMore,
|
||||
hasMore,
|
||||
goToPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
updateFilters,
|
||||
updateSort,
|
||||
} = useVideoList({
|
||||
initialParams: {
|
||||
camera_name: localFilters.cameraName,
|
||||
start_date: localFilters.dateRange?.start,
|
||||
end_date: localFilters.dateRange?.end,
|
||||
limit,
|
||||
include_metadata: true,
|
||||
page: 1, // Start with page 1
|
||||
},
|
||||
autoFetch: true,
|
||||
});
|
||||
|
||||
// Update filters when props change (without causing infinite loops)
|
||||
useEffect(() => {
|
||||
if (filters) {
|
||||
setLocalFilters(filters);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
// Update sort when props change (without causing infinite loops)
|
||||
useEffect(() => {
|
||||
if (sortOptions) {
|
||||
setLocalSort(sortOptions);
|
||||
}
|
||||
}, [sortOptions]);
|
||||
|
||||
const handleVideoClick = (video: any) => {
|
||||
if (onVideoSelect) {
|
||||
onVideoSelect(video);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (hasMore && loading !== 'loading') {
|
||||
loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
const containerClasses = [
|
||||
'video-list',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (loading === 'loading' && videos.length === 0) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading videos...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg className="w-12 h-12 text-red-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading Videos</h3>
|
||||
<p className="text-gray-600 mb-4">{error.message}</p>
|
||||
<button
|
||||
onClick={refetch}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (videos.length === 0) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Videos Found</h3>
|
||||
<p className="text-gray-600">No videos match your current filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{/* Top Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white rounded-xl border border-gray-200 shadow-theme-sm">
|
||||
{/* Page Info */}
|
||||
<PageInfo
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalCount}
|
||||
itemsPerPage={limit}
|
||||
className="text-sm text-gray-600"
|
||||
/>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={goToPage}
|
||||
showFirstLast={true}
|
||||
showPrevNext={true}
|
||||
maxVisiblePages={5}
|
||||
className="justify-center sm:justify-end"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="text-sm text-gray-600">
|
||||
{totalPages > 0 ? (
|
||||
<>Showing page {currentPage} of {totalPages} ({totalCount} total videos)</>
|
||||
) : (
|
||||
<>Showing {videos.length} of {totalCount} videos</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={refetch}
|
||||
disabled={loading === 'loading'}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium transition rounded-lg border bg-white text-gray-700 border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed shadow-theme-xs"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{loading === 'loading' ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Video Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{videos.map((video) => (
|
||||
<VideoCard
|
||||
key={video.file_id}
|
||||
video={video}
|
||||
onClick={onVideoSelect ? handleVideoClick : undefined}
|
||||
showMetadata={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom Pagination */}
|
||||
{totalPages > 1 && videos.length > 0 && (
|
||||
<div className="mt-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white rounded-xl border border-gray-200 shadow-theme-sm">
|
||||
{/* Page Info */}
|
||||
<PageInfo
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalCount}
|
||||
itemsPerPage={limit}
|
||||
className="text-sm text-gray-600"
|
||||
/>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={goToPage}
|
||||
showFirstLast={true}
|
||||
showPrevNext={true}
|
||||
maxVisiblePages={5}
|
||||
className="justify-center sm:justify-end"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Indicator */}
|
||||
{loading === 'loading' && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<div className="text-sm text-gray-600 flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-brand-500 mr-2"></div>
|
||||
Loading videos...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
234
web/src/features/video-streaming/components/VideoModal.tsx
Normal file
234
web/src/features/video-streaming/components/VideoModal.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* VideoModal Component
|
||||
*
|
||||
* A modal component for displaying videos in fullscreen with detailed information.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { type VideoFile } from '../types';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { VideoDebugger } from './VideoDebugger';
|
||||
import { useVideoInfo } from '../hooks/useVideoInfo';
|
||||
import {
|
||||
formatFileSize,
|
||||
formatVideoDate,
|
||||
getFormatDisplayName,
|
||||
getStatusBadgeClass,
|
||||
getResolutionString,
|
||||
formatDuration,
|
||||
isWebCompatible,
|
||||
} from '../utils/videoUtils';
|
||||
|
||||
interface VideoModalProps {
|
||||
video: VideoFile | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const VideoModal: React.FC<VideoModalProps> = ({
|
||||
video,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { videoInfo, streamingInfo, loading, error } = useVideoInfo(
|
||||
video?.file_id || null,
|
||||
{ autoFetch: isOpen && !!video }
|
||||
);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !video) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[999999] overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-75 transition-opacity"
|
||||
onClick={handleBackdropClick}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900 truncate pr-4">
|
||||
{video.filename}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col lg:flex-row max-h-[calc(90vh-80px)]">
|
||||
{/* Video Player */}
|
||||
<div className="flex-1 bg-black">
|
||||
<VideoPlayer
|
||||
fileId={video.file_id}
|
||||
controls={true}
|
||||
className="w-full h-full min-h-[300px] lg:min-h-[400px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sidebar with Video Info */}
|
||||
<div className="w-full lg:w-80 bg-gray-50 overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Status and Format */}
|
||||
<div className="flex items-center space-x-2 flex-wrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClass(video.status)}`}>
|
||||
{video.status}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isWebCompatible(video.format)
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-orange-100 text-orange-800'
|
||||
}`}>
|
||||
{getFormatDisplayName(video.format)}
|
||||
</span>
|
||||
{isWebCompatible(video.format) && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Web Compatible
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Basic Information</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Camera:</dt>
|
||||
<dd className="text-gray-900">{video.camera_name}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">File Size:</dt>
|
||||
<dd className="text-gray-900">{formatFileSize(video.file_size_bytes)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Created:</dt>
|
||||
<dd className="text-gray-900">{formatVideoDate(video.created_at)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Streamable:</dt>
|
||||
<dd className="text-gray-900">{video.is_streamable ? 'Yes' : 'No'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Video Metadata */}
|
||||
{videoInfo?.metadata && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Video Details</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Duration:</dt>
|
||||
<dd className="text-gray-900">{formatDuration(videoInfo.metadata.duration_seconds)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Resolution:</dt>
|
||||
<dd className="text-gray-900">
|
||||
{getResolutionString(videoInfo.metadata.width, videoInfo.metadata.height)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Frame Rate:</dt>
|
||||
<dd className="text-gray-900">{videoInfo.metadata.fps} fps</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Codec:</dt>
|
||||
<dd className="text-gray-900">{videoInfo.metadata.codec}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Aspect Ratio:</dt>
|
||||
<dd className="text-gray-900">{videoInfo.metadata.aspect_ratio.toFixed(2)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming Info */}
|
||||
{streamingInfo && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Streaming Details</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Content Type:</dt>
|
||||
<dd className="text-gray-900">{streamingInfo.content_type}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Range Requests:</dt>
|
||||
<dd className="text-gray-900">{streamingInfo.supports_range_requests ? 'Supported' : 'Not Supported'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Chunk Size:</dt>
|
||||
<dd className="text-gray-900">{formatFileSize(streamingInfo.chunk_size_bytes)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading === 'loading' && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span className="ml-2 text-sm text-gray-600">Loading video details...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<svg className="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error loading video details</h3>
|
||||
<p className="text-sm text-red-700 mt-1">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Debugger (development only) */}
|
||||
<VideoDebugger fileId={video.file_id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
242
web/src/features/video-streaming/components/VideoPlayer.tsx
Normal file
242
web/src/features/video-streaming/components/VideoPlayer.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* VideoPlayer Component
|
||||
*
|
||||
* A reusable video player component with full controls and customization options.
|
||||
* Uses the useVideoPlayer hook for state management and provides a clean interface.
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useState, useEffect } from 'react';
|
||||
import { useVideoPlayer } from '../hooks/useVideoPlayer';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import { type VideoPlayerProps } from '../types';
|
||||
import { formatDuration, getVideoMimeType } from '../utils/videoUtils';
|
||||
|
||||
export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
|
||||
fileId,
|
||||
autoPlay = false,
|
||||
controls = true,
|
||||
width = '100%',
|
||||
height = 'auto',
|
||||
className = '',
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
onError,
|
||||
}, forwardedRef) => {
|
||||
const [videoInfo, setVideoInfo] = useState<{ filename?: string; mimeType: string; isStreamable?: boolean }>({
|
||||
mimeType: 'video/mp4' // Default to MP4
|
||||
});
|
||||
|
||||
const { state, actions, ref } = useVideoPlayer({
|
||||
autoPlay,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
onError,
|
||||
});
|
||||
|
||||
// Combine refs
|
||||
React.useImperativeHandle(forwardedRef, () => ref.current!, [ref]);
|
||||
|
||||
const streamingUrl = videoApiService.getStreamingUrl(fileId);
|
||||
|
||||
// Fetch video info to determine MIME type and streamability
|
||||
useEffect(() => {
|
||||
const fetchVideoInfo = async () => {
|
||||
try {
|
||||
const info = await videoApiService.getVideoInfo(fileId);
|
||||
if (info.file_id) {
|
||||
// Extract filename from file_id or use a default pattern
|
||||
const filename = info.file_id.includes('.') ? info.file_id : `${info.file_id}.mp4`;
|
||||
const mimeType = getVideoMimeType(filename);
|
||||
setVideoInfo({
|
||||
filename,
|
||||
mimeType,
|
||||
isStreamable: info.is_streamable
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch video info, using default MIME type:', error);
|
||||
// Keep default MP4 MIME type, assume not streamable
|
||||
setVideoInfo(prev => ({ ...prev, isStreamable: false }));
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideoInfo();
|
||||
}, [fileId]);
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const percentage = clickX / rect.width;
|
||||
const newTime = percentage * state.duration;
|
||||
|
||||
actions.seek(newTime);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
actions.setVolume(parseFloat(e.target.value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`video-player relative ${className}`} style={{ width, height }}>
|
||||
{/* Video Element */}
|
||||
<video
|
||||
ref={ref}
|
||||
className="w-full h-full bg-black"
|
||||
controls={!controls || state.error} // Use native controls if custom controls are disabled or there's an error
|
||||
style={{ width, height }}
|
||||
playsInline // Important for iOS compatibility
|
||||
preload="metadata" // Load metadata first for better UX
|
||||
>
|
||||
<source src={streamingUrl} type={videoInfo.mimeType} />
|
||||
{/* Fallback for MP4 if original format fails */}
|
||||
{videoInfo.mimeType !== 'video/mp4' && (
|
||||
<source src={streamingUrl} type="video/mp4" />
|
||||
)}
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{state.isLoading && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="text-white text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<div className="text-lg">Loading video...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Overlay */}
|
||||
{state.error && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-75 flex items-center justify-center">
|
||||
<div className="text-red-400 text-center">
|
||||
<div className="text-lg mb-2">Playback Error</div>
|
||||
<div className="text-sm">{state.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Controls */}
|
||||
{controls && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-4">
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div
|
||||
className="w-full h-2 bg-gray-600 rounded cursor-pointer"
|
||||
onClick={handleSeek}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded"
|
||||
style={{
|
||||
width: `${state.duration > 0 ? (state.currentTime / state.duration) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control Bar */}
|
||||
<div className="flex items-center justify-between text-white">
|
||||
{/* Left Controls */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Play/Pause Button */}
|
||||
<button
|
||||
onClick={actions.togglePlay}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
disabled={state.isLoading}
|
||||
>
|
||||
{state.isPlaying ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Skip Backward */}
|
||||
<button
|
||||
onClick={() => actions.skip(-10)}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
title="Skip backward 10s"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M15.707 15.707a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 111.414 1.414L11.414 9H17a1 1 0 110 2h-5.586l3.293 3.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Skip Forward */}
|
||||
<button
|
||||
onClick={() => actions.skip(10)}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
title="Skip forward 10s"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0l5 5a1 1 0 010 1.414l-5 5a1 1 0 01-1.414-1.414L8.586 11H3a1 1 0 110-2h5.586L4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Time Display */}
|
||||
<div className="text-sm">
|
||||
{formatDuration(state.currentTime)} / {formatDuration(state.duration)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={actions.toggleMute}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
>
|
||||
{state.isMuted || state.volume === 0 ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.776L4.83 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.83l3.553-3.776a1 1 0 011.617.776zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.776L4.83 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.83l3.553-3.776a1 1 0 011.617.776zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={state.volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<button
|
||||
onClick={actions.toggleFullscreen}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
>
|
||||
{state.isFullscreen ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VideoPlayer.displayName = 'VideoPlayer';
|
||||
138
web/src/features/video-streaming/components/VideoThumbnail.tsx
Normal file
138
web/src/features/video-streaming/components/VideoThumbnail.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* VideoThumbnail Component
|
||||
*
|
||||
* A reusable component for displaying video thumbnails with loading states and error handling.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import { thumbnailCache } from '../utils/thumbnailCache';
|
||||
import { type VideoThumbnailProps } from '../types';
|
||||
|
||||
export const VideoThumbnail: React.FC<VideoThumbnailProps> = ({
|
||||
fileId,
|
||||
timestamp = 0,
|
||||
width = 320,
|
||||
height = 240,
|
||||
alt = 'Video thumbnail',
|
||||
className = '',
|
||||
onClick,
|
||||
}) => {
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadThumbnail = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check cache first
|
||||
const cachedUrl = thumbnailCache.get(fileId, timestamp, width, height);
|
||||
if (cachedUrl && isMounted) {
|
||||
setThumbnailUrl(cachedUrl);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch from API if not cached
|
||||
const blob = await videoApiService.getThumbnailBlob(fileId, {
|
||||
timestamp,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
// Store in cache and get URL
|
||||
const url = thumbnailCache.set(fileId, timestamp, width, height, blob);
|
||||
setThumbnailUrl(url);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load thumbnail');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadThumbnail();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
// Note: We don't revoke the URL here since it's managed by the cache
|
||||
};
|
||||
}, [fileId, timestamp, width, height]);
|
||||
|
||||
// Note: URL cleanup is now handled by the thumbnail cache
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick && !isLoading && !error) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const containerClasses = [
|
||||
'relative overflow-hidden bg-gray-200 rounded',
|
||||
onClick && !isLoading && !error ? 'cursor-pointer hover:opacity-80 transition-opacity' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={containerClasses}
|
||||
style={{ width, height }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100 text-gray-500 text-sm p-2 text-center">
|
||||
<div>
|
||||
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>Failed to load thumbnail</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Image */}
|
||||
{thumbnailUrl && !isLoading && !error && (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setError('Failed to display thumbnail')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Play Overlay */}
|
||||
{onClick && !isLoading && !error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black bg-opacity-30">
|
||||
<div className="bg-white bg-opacity-90 rounded-full p-3">
|
||||
<svg className="w-6 h-6 text-gray-800" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp Badge */}
|
||||
{timestamp > 0 && !isLoading && !error && (
|
||||
<div className="absolute bottom-2 right-2 bg-black bg-opacity-75 text-white text-xs px-2 py-1 rounded">
|
||||
{Math.floor(timestamp / 60)}:{(timestamp % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
26
web/src/features/video-streaming/components/index.ts
Normal file
26
web/src/features/video-streaming/components/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Video Streaming Components - Index
|
||||
*
|
||||
* Centralized export for all video streaming components.
|
||||
* This makes it easy to import components from a single location.
|
||||
*/
|
||||
|
||||
export { VideoPlayer } from './VideoPlayer';
|
||||
export { VideoThumbnail } from './VideoThumbnail';
|
||||
export { VideoCard } from './VideoCard';
|
||||
export { VideoList } from './VideoList';
|
||||
export { VideoModal } from './VideoModal';
|
||||
export { Pagination, PageInfo } from './Pagination';
|
||||
export { ApiStatusIndicator } from './ApiStatusIndicator';
|
||||
export { VideoErrorBoundary, withVideoErrorBoundary } from './VideoErrorBoundary';
|
||||
export { PerformanceDashboard } from './PerformanceDashboard';
|
||||
export { VideoDebugger } from './VideoDebugger';
|
||||
|
||||
// Re-export component prop types for convenience
|
||||
export type {
|
||||
VideoPlayerProps,
|
||||
VideoThumbnailProps,
|
||||
VideoCardProps,
|
||||
VideoListProps,
|
||||
PaginationProps,
|
||||
} from '../types';
|
||||
16
web/src/features/video-streaming/hooks/index.ts
Normal file
16
web/src/features/video-streaming/hooks/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Video Streaming Hooks - Index
|
||||
*
|
||||
* Centralized export for all video streaming hooks.
|
||||
* This makes it easy to import hooks from a single location.
|
||||
*/
|
||||
|
||||
export { useVideoList, type UseVideoListReturn } from './useVideoList';
|
||||
export { useVideoPlayer, type UseVideoPlayerReturn, type VideoPlayerState } from './useVideoPlayer';
|
||||
export { useVideoInfo, type UseVideoInfoReturn } from './useVideoInfo';
|
||||
|
||||
// Re-export types that are commonly used with hooks
|
||||
export type {
|
||||
VideoListFilters,
|
||||
VideoListSortOptions,
|
||||
} from '../types';
|
||||
191
web/src/features/video-streaming/hooks/useVideoInfo.ts
Normal file
191
web/src/features/video-streaming/hooks/useVideoInfo.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* useVideoInfo Hook
|
||||
*
|
||||
* Custom React hook for fetching and managing video metadata and streaming information.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import {
|
||||
type VideoInfoResponse,
|
||||
type VideoStreamingInfo,
|
||||
type VideoError,
|
||||
type LoadingState
|
||||
} from '../types';
|
||||
|
||||
export interface UseVideoInfoReturn {
|
||||
videoInfo: VideoInfoResponse | null;
|
||||
streamingInfo: VideoStreamingInfo | null;
|
||||
loading: LoadingState;
|
||||
error: VideoError | null;
|
||||
refetch: () => Promise<void>;
|
||||
clearCache: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface UseVideoInfoOptions {
|
||||
autoFetch?: boolean;
|
||||
cacheKey?: string;
|
||||
}
|
||||
|
||||
export function useVideoInfo(
|
||||
fileId: string | null,
|
||||
options: UseVideoInfoOptions = {}
|
||||
) {
|
||||
const { autoFetch = true, cacheKey = 'default' } = options;
|
||||
|
||||
// State
|
||||
const [videoInfo, setVideoInfo] = useState<VideoInfoResponse | null>(null);
|
||||
const [streamingInfo, setStreamingInfo] = useState<VideoStreamingInfo | null>(null);
|
||||
const [loading, setLoading] = useState<LoadingState>('idle');
|
||||
const [error, setError] = useState<VideoError | null>(null);
|
||||
|
||||
// Refs for cleanup and caching
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const cacheRef = useRef<Map<string, {
|
||||
videoInfo: VideoInfoResponse;
|
||||
streamingInfo: VideoStreamingInfo;
|
||||
timestamp: number;
|
||||
}>>(new Map());
|
||||
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/**
|
||||
* Check if cached data is still valid
|
||||
*/
|
||||
const isCacheValid = useCallback((timestamp: number): boolean => {
|
||||
return Date.now() - timestamp < CACHE_DURATION;
|
||||
}, [CACHE_DURATION]);
|
||||
|
||||
/**
|
||||
* Fetch video information
|
||||
*/
|
||||
const fetchVideoInfo = useCallback(async (id: string): Promise<void> => {
|
||||
// Cancel any ongoing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
setLoading('loading');
|
||||
setError(null);
|
||||
|
||||
// Check cache first
|
||||
const key = `${cacheKey}_${id}`;
|
||||
const cached = cacheRef.current.get(key);
|
||||
|
||||
if (cached && isCacheValid(cached.timestamp)) {
|
||||
setVideoInfo(cached.videoInfo);
|
||||
setStreamingInfo(cached.streamingInfo);
|
||||
setLoading('success');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch both video info and streaming info in parallel
|
||||
const [videoInfoResponse, streamingInfoResponse] = await Promise.all([
|
||||
videoApiService.getVideoInfo(id),
|
||||
videoApiService.getStreamingInfo(id)
|
||||
]);
|
||||
|
||||
// Check if request was aborted
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
cacheRef.current.set(key, {
|
||||
videoInfo: videoInfoResponse,
|
||||
streamingInfo: streamingInfoResponse,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Update state
|
||||
setVideoInfo(videoInfoResponse);
|
||||
setStreamingInfo(streamingInfoResponse);
|
||||
setLoading('success');
|
||||
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoError: VideoError = err instanceof Error
|
||||
? { code: 'FETCH_ERROR', message: err.message, details: err }
|
||||
: { code: 'UNKNOWN_ERROR', message: 'An unknown error occurred' };
|
||||
|
||||
setError(videoError);
|
||||
setLoading('error');
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, [cacheKey, isCacheValid]);
|
||||
|
||||
/**
|
||||
* Refetch video information
|
||||
*/
|
||||
const refetch = useCallback(async (): Promise<void> => {
|
||||
if (!fileId) return;
|
||||
await fetchVideoInfo(fileId);
|
||||
}, [fileId, fetchVideoInfo]);
|
||||
|
||||
/**
|
||||
* Clear cache for current video
|
||||
*/
|
||||
const clearCache = useCallback((): void => {
|
||||
if (!fileId) return;
|
||||
const key = `${cacheKey}_${fileId}`;
|
||||
cacheRef.current.delete(key);
|
||||
}, [fileId, cacheKey]);
|
||||
|
||||
/**
|
||||
* Reset state
|
||||
*/
|
||||
const reset = useCallback((): void => {
|
||||
setVideoInfo(null);
|
||||
setStreamingInfo(null);
|
||||
setLoading('idle');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Auto-fetch when fileId changes
|
||||
useEffect(() => {
|
||||
if (fileId && autoFetch) {
|
||||
fetchVideoInfo(fileId);
|
||||
} else if (!fileId) {
|
||||
reset();
|
||||
}
|
||||
|
||||
// Cleanup on unmount or fileId change
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [fileId, autoFetch, fetchVideoInfo, reset]);
|
||||
|
||||
// Cleanup cache periodically
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
for (const [key, value] of cacheRef.current.entries()) {
|
||||
if (!isCacheValid(value.timestamp)) {
|
||||
cacheRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
}, CACHE_DURATION);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isCacheValid, CACHE_DURATION]);
|
||||
|
||||
return {
|
||||
videoInfo,
|
||||
streamingInfo,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
clearCache,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
262
web/src/features/video-streaming/hooks/useVideoList.ts
Normal file
262
web/src/features/video-streaming/hooks/useVideoList.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* useVideoList Hook
|
||||
*
|
||||
* Custom React hook for managing video list state, fetching, filtering, and pagination.
|
||||
* Provides a clean interface for components to interact with video data.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import {
|
||||
type VideoFile,
|
||||
type VideoListParams,
|
||||
type VideoError,
|
||||
type LoadingState,
|
||||
type VideoListFilters,
|
||||
type VideoListSortOptions
|
||||
} from '../types';
|
||||
|
||||
export interface UseVideoListReturn {
|
||||
videos: VideoFile[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
loading: LoadingState;
|
||||
error: VideoError | null;
|
||||
refetch: () => Promise<void>;
|
||||
loadMore: () => Promise<void>;
|
||||
hasMore: boolean;
|
||||
goToPage: (page: number) => Promise<void>;
|
||||
nextPage: () => Promise<void>;
|
||||
previousPage: () => Promise<void>;
|
||||
updateFilters: (filters: VideoListFilters) => void;
|
||||
updateSort: (sortOptions: VideoListSortOptions) => void;
|
||||
clearCache: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
import { filterVideos, sortVideos } from '../utils/videoUtils';
|
||||
|
||||
interface UseVideoListOptions {
|
||||
initialParams?: VideoListParams;
|
||||
autoFetch?: boolean;
|
||||
cacheKey?: string;
|
||||
}
|
||||
|
||||
export function useVideoList(options: UseVideoListOptions = {}) {
|
||||
const {
|
||||
initialParams = {},
|
||||
autoFetch = true,
|
||||
cacheKey = 'default'
|
||||
} = options;
|
||||
|
||||
// State
|
||||
const [videos, setVideos] = useState<VideoFile[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [loading, setLoading] = useState<LoadingState>('idle');
|
||||
const [error, setError] = useState<VideoError | null>(null);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [currentParams, setCurrentParams] = useState<VideoListParams>(initialParams);
|
||||
|
||||
// Refs for cleanup and caching
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Fetch videos from API
|
||||
*/
|
||||
const fetchVideos = useCallback(async (
|
||||
params: VideoListParams = initialParams,
|
||||
append: boolean = false
|
||||
): Promise<void> => {
|
||||
// Cancel any ongoing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
setLoading('loading');
|
||||
setError(null);
|
||||
|
||||
// Fetch from API
|
||||
const response = await videoApiService.getVideos(params);
|
||||
|
||||
// Check if request was aborted
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update state
|
||||
setVideos(append ? prev => [...prev, ...response.videos] : response.videos);
|
||||
setTotalCount(response.total_count);
|
||||
|
||||
// Update pagination state
|
||||
if (response.page && response.total_pages) {
|
||||
setCurrentPage(response.page);
|
||||
setTotalPages(response.total_pages);
|
||||
setHasMore(response.has_next || false);
|
||||
} else {
|
||||
// Fallback for offset-based pagination
|
||||
setHasMore(response.videos.length === (params.limit || 50));
|
||||
}
|
||||
|
||||
setLoading('success');
|
||||
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoError: VideoError = err instanceof Error
|
||||
? { code: 'FETCH_ERROR', message: err.message, details: err }
|
||||
: { code: 'UNKNOWN_ERROR', message: 'An unknown error occurred' };
|
||||
|
||||
setError(videoError);
|
||||
setLoading('error');
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, [initialParams]);
|
||||
|
||||
/**
|
||||
* Refetch videos with current page
|
||||
*/
|
||||
const refetch = useCallback(async (): Promise<void> => {
|
||||
const currentParams = {
|
||||
...initialParams,
|
||||
page: currentPage,
|
||||
limit: initialParams.limit || 20,
|
||||
};
|
||||
await fetchVideos(currentParams, false);
|
||||
}, [fetchVideos, initialParams, currentPage]);
|
||||
|
||||
/**
|
||||
* Load more videos (pagination) - for backward compatibility
|
||||
*/
|
||||
const loadMore = useCallback(async (): Promise<void> => {
|
||||
if (!hasMore || loading === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = videos.length;
|
||||
const params = { ...initialParams, offset };
|
||||
await fetchVideos(params, true);
|
||||
}, [hasMore, loading, videos.length, initialParams, fetchVideos]);
|
||||
|
||||
/**
|
||||
* Go to specific page
|
||||
*/
|
||||
const goToPage = useCallback(async (page: number): Promise<void> => {
|
||||
if (page < 1 || (totalPages > 0 && page > totalPages) || loading === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = { ...currentParams, page, limit: currentParams.limit || 20 };
|
||||
setCurrentParams(params);
|
||||
await fetchVideos(params, false);
|
||||
}, [currentParams, totalPages, loading, fetchVideos]);
|
||||
|
||||
/**
|
||||
* Go to next page
|
||||
*/
|
||||
const nextPage = useCallback(async (): Promise<void> => {
|
||||
if (currentPage < totalPages) {
|
||||
await goToPage(currentPage + 1);
|
||||
}
|
||||
}, [currentPage, totalPages, goToPage]);
|
||||
|
||||
/**
|
||||
* Go to previous page
|
||||
*/
|
||||
const previousPage = useCallback(async (): Promise<void> => {
|
||||
if (currentPage > 1) {
|
||||
await goToPage(currentPage - 1);
|
||||
}
|
||||
}, [currentPage, goToPage]);
|
||||
|
||||
/**
|
||||
* Update filters and refetch
|
||||
*/
|
||||
const updateFilters = useCallback((filters: VideoListFilters): void => {
|
||||
const newParams: VideoListParams = {
|
||||
...initialParams,
|
||||
camera_name: filters.cameraName,
|
||||
start_date: filters.dateRange?.start,
|
||||
end_date: filters.dateRange?.end,
|
||||
page: 1, // Reset to first page when filters change
|
||||
limit: initialParams.limit || 20,
|
||||
};
|
||||
|
||||
setCurrentParams(newParams);
|
||||
fetchVideos(newParams, false);
|
||||
}, [initialParams, fetchVideos]);
|
||||
|
||||
/**
|
||||
* Update sort options and refetch
|
||||
*/
|
||||
const updateSort = useCallback((sortOptions: VideoListSortOptions): void => {
|
||||
// Since the API doesn't support sorting, we'll sort locally
|
||||
setVideos(prev => sortVideos(prev, sortOptions.field, sortOptions.direction));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear cache (placeholder for future caching implementation)
|
||||
*/
|
||||
const clearCache = useCallback((): void => {
|
||||
// TODO: Implement cache clearing when caching is added
|
||||
console.log('Cache cleared');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset to initial state
|
||||
*/
|
||||
const reset = useCallback((): void => {
|
||||
setVideos([]);
|
||||
setTotalCount(0);
|
||||
setCurrentPage(1);
|
||||
setTotalPages(0);
|
||||
setLoading('idle');
|
||||
setError(null);
|
||||
setHasMore(true);
|
||||
}, []);
|
||||
|
||||
// Auto-fetch on mount only
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchVideos(initialParams, false);
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []); // Empty dependency array - only run once on mount
|
||||
|
||||
return {
|
||||
videos,
|
||||
totalCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
loadMore,
|
||||
hasMore,
|
||||
// Pagination methods
|
||||
goToPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
// Additional utility methods
|
||||
updateFilters,
|
||||
updateSort,
|
||||
clearCache,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
342
web/src/features/video-streaming/hooks/useVideoPlayer.ts
Normal file
342
web/src/features/video-streaming/hooks/useVideoPlayer.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* useVideoPlayer Hook
|
||||
*
|
||||
* Custom React hook for managing video player state and controls.
|
||||
* Provides a comprehensive interface for video playback functionality.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
// Video player state interface
|
||||
export interface VideoPlayerState {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isFullscreen: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface UseVideoPlayerReturn {
|
||||
state: VideoPlayerState;
|
||||
actions: {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlay: () => void;
|
||||
seek: (time: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
toggleMute: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
skip: (seconds: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
ref: React.RefObject<HTMLVideoElement>;
|
||||
}
|
||||
|
||||
interface UseVideoPlayerOptions {
|
||||
autoPlay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onEnded?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
onDurationChange?: (duration: number) => void;
|
||||
}
|
||||
|
||||
export function useVideoPlayer(options: UseVideoPlayerOptions = {}) {
|
||||
const {
|
||||
autoPlay = false,
|
||||
loop = false,
|
||||
muted = false,
|
||||
volume = 1,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
onDurationChange,
|
||||
} = options;
|
||||
|
||||
// Video element ref
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Player state
|
||||
const [state, setState] = useState<VideoPlayerState>({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: volume,
|
||||
isMuted: muted,
|
||||
isFullscreen: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Update state helper
|
||||
*/
|
||||
const updateState = useCallback((updates: Partial<VideoPlayerState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Play video
|
||||
*/
|
||||
const play = useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
try {
|
||||
updateState({ isLoading: true, error: null });
|
||||
await video.play();
|
||||
updateState({ isPlaying: true, isLoading: false });
|
||||
onPlay?.();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to play video';
|
||||
updateState({ isLoading: false, error: errorMessage });
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
}, [updateState, onPlay, onError]);
|
||||
|
||||
/**
|
||||
* Pause video
|
||||
*/
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.pause();
|
||||
updateState({ isPlaying: false });
|
||||
onPause?.();
|
||||
}, [updateState, onPause]);
|
||||
|
||||
/**
|
||||
* Toggle play/pause
|
||||
*/
|
||||
const togglePlay = useCallback(() => {
|
||||
if (state.isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}, [state.isPlaying, play, pause]);
|
||||
|
||||
/**
|
||||
* Seek to specific time
|
||||
*/
|
||||
const seek = useCallback((time: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.currentTime = Math.max(0, Math.min(time, video.duration || 0));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set volume (0-1)
|
||||
*/
|
||||
const setVolume = useCallback((newVolume: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
||||
video.volume = clampedVolume;
|
||||
updateState({ volume: clampedVolume });
|
||||
}, [updateState]);
|
||||
|
||||
/**
|
||||
* Toggle mute
|
||||
*/
|
||||
const toggleMute = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.muted = !video.muted;
|
||||
updateState({ isMuted: video.muted });
|
||||
}, [updateState]);
|
||||
|
||||
/**
|
||||
* Enter/exit fullscreen
|
||||
*/
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await video.requestFullscreen();
|
||||
updateState({ isFullscreen: true });
|
||||
} else {
|
||||
await document.exitFullscreen();
|
||||
updateState({ isFullscreen: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Fullscreen not supported or failed:', error);
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
/**
|
||||
* Skip forward/backward
|
||||
*/
|
||||
const skip = useCallback((seconds: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const newTime = video.currentTime + seconds;
|
||||
seek(newTime);
|
||||
}, [seek]);
|
||||
|
||||
/**
|
||||
* Set playback rate
|
||||
*/
|
||||
const setPlaybackRate = useCallback((rate: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.playbackRate = Math.max(0.25, Math.min(4, rate));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset video to beginning
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.currentTime = 0;
|
||||
pause();
|
||||
}, [pause]);
|
||||
|
||||
// Event handlers
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleLoadStart = () => {
|
||||
updateState({ isLoading: true, error: null });
|
||||
|
||||
// Set a timeout to detect if loading takes too long
|
||||
const loadTimeout = setTimeout(() => {
|
||||
if (video && video.readyState < 2) { // HAVE_CURRENT_DATA
|
||||
updateState({
|
||||
isLoading: false,
|
||||
error: 'Video loading timeout. The video may not be accessible or there may be a network issue.'
|
||||
});
|
||||
}
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
// Store timeout ID to clear it later
|
||||
(video as any)._loadTimeout = loadTimeout;
|
||||
};
|
||||
|
||||
const handleLoadedData = () => {
|
||||
updateState({ isLoading: false });
|
||||
|
||||
// Clear the loading timeout
|
||||
if ((video as any)._loadTimeout) {
|
||||
clearTimeout((video as any)._loadTimeout);
|
||||
(video as any)._loadTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
updateState({ currentTime: video.currentTime });
|
||||
onTimeUpdate?.(video.currentTime);
|
||||
};
|
||||
|
||||
const handleDurationChange = () => {
|
||||
updateState({ duration: video.duration });
|
||||
onDurationChange?.(video.duration);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
updateState({ isPlaying: true });
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
updateState({ isPlaying: false });
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
updateState({ isPlaying: false });
|
||||
onEnded?.();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
const errorMessage = video.error?.message || 'Video playback error';
|
||||
updateState({ isLoading: false, error: errorMessage, isPlaying: false });
|
||||
onError?.(errorMessage);
|
||||
|
||||
// Clear the loading timeout
|
||||
if ((video as any)._loadTimeout) {
|
||||
clearTimeout((video as any)._loadTimeout);
|
||||
(video as any)._loadTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
updateState({
|
||||
volume: video.volume,
|
||||
isMuted: video.muted
|
||||
});
|
||||
};
|
||||
|
||||
const handleFullscreenChange = () => {
|
||||
updateState({ isFullscreen: !!document.fullscreenElement });
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
video.addEventListener('loadstart', handleLoadStart);
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('durationchange', handleDurationChange);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('ended', handleEnded);
|
||||
video.addEventListener('error', handleError);
|
||||
video.addEventListener('volumechange', handleVolumeChange);
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
|
||||
// Set initial properties
|
||||
video.autoplay = autoPlay;
|
||||
video.loop = loop;
|
||||
video.muted = muted;
|
||||
video.volume = volume;
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
video.removeEventListener('loadstart', handleLoadStart);
|
||||
video.removeEventListener('loadeddata', handleLoadedData);
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('durationchange', handleDurationChange);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
video.removeEventListener('error', handleError);
|
||||
video.removeEventListener('volumechange', handleVolumeChange);
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, [autoPlay, loop, muted, volume, updateState, onTimeUpdate, onDurationChange, onEnded, onError]);
|
||||
|
||||
return {
|
||||
state,
|
||||
actions: {
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
seek,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleFullscreen,
|
||||
skip,
|
||||
setPlaybackRate,
|
||||
reset,
|
||||
},
|
||||
ref: videoRef,
|
||||
};
|
||||
}
|
||||
24
web/src/features/video-streaming/index.ts
Normal file
24
web/src/features/video-streaming/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Video Streaming Feature - Main Export
|
||||
*
|
||||
* This is the main entry point for the video streaming feature.
|
||||
* It exports all the public APIs that other parts of the application can use.
|
||||
*/
|
||||
|
||||
// Components
|
||||
export * from './components';
|
||||
|
||||
// Hooks
|
||||
export * from './hooks';
|
||||
|
||||
// Services
|
||||
export { videoApiService, VideoApiService } from './services/videoApi';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Utils
|
||||
export * from './utils/videoUtils';
|
||||
|
||||
// Main feature component
|
||||
export { VideoStreamingPage } from './VideoStreamingPage';
|
||||
283
web/src/features/video-streaming/services/videoApi.ts
Normal file
283
web/src/features/video-streaming/services/videoApi.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Video Streaming API Service
|
||||
*
|
||||
* This service handles all API interactions for the video streaming feature.
|
||||
* It provides a clean interface for components to interact with the video API
|
||||
* without knowing the implementation details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type VideoListResponse,
|
||||
type VideoInfoResponse,
|
||||
type VideoStreamingInfo,
|
||||
type VideoListParams,
|
||||
type ThumbnailParams,
|
||||
} from '../types';
|
||||
import { performanceMonitor } from '../utils/performanceMonitor';
|
||||
|
||||
// Configuration - Use environment variable or default to vision container
|
||||
// The API is accessible at vision:8000 in the current setup
|
||||
const API_BASE_URL = import.meta.env.VITE_VISION_API_URL || 'http://vision:8000';
|
||||
|
||||
/**
|
||||
* Custom error class for video API errors
|
||||
*/
|
||||
export class VideoApiError extends Error {
|
||||
public code: string;
|
||||
public details?: unknown;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'VideoApiError';
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to handle API responses
|
||||
*/
|
||||
async function handleApiResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new VideoApiError(
|
||||
`HTTP_${response.status}`,
|
||||
`API request failed: ${response.statusText}`,
|
||||
{ status: response.status, body: errorText }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
throw new VideoApiError(
|
||||
'INVALID_RESPONSE',
|
||||
'Expected JSON response from API'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from parameters
|
||||
*/
|
||||
function buildQueryString(params: VideoListParams | ThumbnailParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Video API Service Class
|
||||
*/
|
||||
export class VideoApiService {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string = API_BASE_URL) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of videos with filters (without pagination)
|
||||
*/
|
||||
private totalCountCache = new Map<string, { count: number; timestamp: number }>();
|
||||
private readonly CACHE_DURATION = 30000; // 30 seconds cache
|
||||
|
||||
private async getTotalCount(params: Omit<VideoListParams, 'limit' | 'offset' | 'page'>): Promise<number> {
|
||||
// Create cache key from params
|
||||
const cacheKey = JSON.stringify(params);
|
||||
const cached = this.totalCountCache.get(cacheKey);
|
||||
|
||||
// Return cached result if still valid
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) {
|
||||
return cached.count;
|
||||
}
|
||||
|
||||
const queryString = buildQueryString({ ...params, limit: 1000 }); // Use high limit to get accurate total
|
||||
const url = `${this.baseUrl}/videos/${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<VideoListResponse>(response);
|
||||
const count = result.videos.length; // Since backend returns wrong total_count, count the actual videos
|
||||
|
||||
// Cache the result
|
||||
this.totalCountCache.set(cacheKey, { count, timestamp: Date.now() });
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of videos with optional filtering
|
||||
*/
|
||||
async getVideos(params: VideoListParams = {}): Promise<VideoListResponse> {
|
||||
return performanceMonitor.trackOperation('get_videos', async () => {
|
||||
// Convert page-based params to offset-based for API compatibility
|
||||
const apiParams = { ...params };
|
||||
|
||||
// If page is provided, convert to offset
|
||||
if (params.page && params.limit) {
|
||||
apiParams.offset = (params.page - 1) * params.limit;
|
||||
delete apiParams.page; // Remove page param as API expects offset
|
||||
}
|
||||
|
||||
const queryString = buildQueryString(apiParams);
|
||||
const url = `${this.baseUrl}/videos/${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<VideoListResponse>(response);
|
||||
|
||||
// Add pagination metadata if page was requested
|
||||
if (params.page && params.limit) {
|
||||
// Get accurate total count by calling without pagination
|
||||
const totalCount = await this.getTotalCount({
|
||||
camera_name: params.camera_name,
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
include_metadata: params.include_metadata,
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(totalCount / params.limit);
|
||||
return {
|
||||
...result,
|
||||
total_count: totalCount, // Use accurate total count
|
||||
page: params.page,
|
||||
total_pages: totalPages,
|
||||
has_next: params.page < totalPages,
|
||||
has_previous: params.page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific video
|
||||
*/
|
||||
async getVideoInfo(fileId: string): Promise<VideoInfoResponse> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/videos/${fileId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return await handleApiResponse<VideoInfoResponse>(response);
|
||||
} catch (error) {
|
||||
if (error instanceof VideoApiError) {
|
||||
throw error;
|
||||
}
|
||||
throw new VideoApiError(
|
||||
'NETWORK_ERROR',
|
||||
`Failed to fetch video info for ${fileId}`,
|
||||
{ originalError: error, fileId }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming information for a video
|
||||
*/
|
||||
async getStreamingInfo(fileId: string): Promise<VideoStreamingInfo> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/videos/${fileId}/info`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return await handleApiResponse<VideoStreamingInfo>(response);
|
||||
} catch (error) {
|
||||
if (error instanceof VideoApiError) {
|
||||
throw error;
|
||||
}
|
||||
throw new VideoApiError(
|
||||
'NETWORK_ERROR',
|
||||
`Failed to fetch streaming info for ${fileId}`,
|
||||
{ originalError: error, fileId }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the streaming URL for a video
|
||||
*/
|
||||
getStreamingUrl(fileId: string): string {
|
||||
return `${this.baseUrl}/videos/${fileId}/stream`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thumbnail URL for a video
|
||||
*/
|
||||
getThumbnailUrl(fileId: string, params: ThumbnailParams = {}): string {
|
||||
const queryString = buildQueryString(params);
|
||||
return `${this.baseUrl}/videos/${fileId}/thumbnail${queryString ? `?${queryString}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download thumbnail as blob
|
||||
*/
|
||||
async getThumbnailBlob(fileId: string, params: ThumbnailParams = {}): Promise<Blob> {
|
||||
return performanceMonitor.trackOperation('get_thumbnail', async () => {
|
||||
const url = this.getThumbnailUrl(fileId, params);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new VideoApiError(
|
||||
`HTTP_${response.status}`,
|
||||
`Failed to fetch thumbnail: ${response.statusText}`,
|
||||
{ status: response.status, fileId }
|
||||
);
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
}, { fileId, params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the video API is available
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/videos/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a default instance
|
||||
export const videoApiService = new VideoApiService();
|
||||
|
||||
// Export utility functions
|
||||
export { buildQueryString, handleApiResponse };
|
||||
163
web/src/features/video-streaming/types/index.ts
Normal file
163
web/src/features/video-streaming/types/index.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Video Streaming Feature Types
|
||||
*
|
||||
* This file contains all TypeScript type definitions for the video streaming feature.
|
||||
* Following the modular architecture pattern where types are centralized and reusable.
|
||||
* Updated to fix import issues.
|
||||
*/
|
||||
|
||||
// Base video information from the API
|
||||
export interface VideoFile {
|
||||
file_id: string;
|
||||
camera_name: string;
|
||||
filename: string;
|
||||
file_size_bytes: number;
|
||||
format: string;
|
||||
status: 'completed' | 'processing' | 'failed';
|
||||
created_at: string;
|
||||
is_streamable: boolean;
|
||||
needs_conversion: boolean;
|
||||
}
|
||||
|
||||
// Extended video information with metadata
|
||||
export interface VideoWithMetadata extends VideoFile {
|
||||
metadata?: {
|
||||
duration_seconds: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
codec: string;
|
||||
aspect_ratio: number;
|
||||
};
|
||||
}
|
||||
|
||||
// API response for video list
|
||||
export interface VideoListResponse {
|
||||
videos: VideoFile[];
|
||||
total_count: number;
|
||||
page?: number;
|
||||
total_pages?: number;
|
||||
has_next?: boolean;
|
||||
has_previous?: boolean;
|
||||
}
|
||||
|
||||
// API response for video info
|
||||
export interface VideoInfoResponse {
|
||||
file_id: string;
|
||||
metadata: {
|
||||
duration_seconds: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
codec: string;
|
||||
aspect_ratio: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Streaming technical information
|
||||
export interface VideoStreamingInfo {
|
||||
file_id: string;
|
||||
file_size_bytes: number;
|
||||
content_type: string;
|
||||
supports_range_requests: boolean;
|
||||
chunk_size_bytes: number;
|
||||
}
|
||||
|
||||
// Query parameters for video list API
|
||||
export interface VideoListParams {
|
||||
camera_name?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
limit?: number;
|
||||
include_metadata?: boolean;
|
||||
page?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// Thumbnail request parameters
|
||||
export interface ThumbnailParams {
|
||||
timestamp?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// Video player state is now defined in useVideoPlayer hook to avoid circular imports
|
||||
|
||||
// Video list filter and sort options
|
||||
export interface VideoListFilters {
|
||||
cameraName?: string;
|
||||
dateRange?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
status?: VideoFile['status'];
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export interface VideoListSortOptions {
|
||||
field: 'created_at' | 'file_size_bytes' | 'camera_name' | 'filename';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Component props interfaces
|
||||
export interface VideoPlayerProps {
|
||||
fileId: string;
|
||||
autoPlay?: boolean;
|
||||
controls?: boolean;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onEnded?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export interface VideoCardProps {
|
||||
video: VideoFile;
|
||||
onClick?: (video: VideoFile) => void;
|
||||
showMetadata?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface VideoListProps {
|
||||
filters?: VideoListFilters;
|
||||
sortOptions?: VideoListSortOptions;
|
||||
limit?: number;
|
||||
onVideoSelect?: (video: VideoFile) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Pagination component props
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
showFirstLast?: boolean;
|
||||
showPrevNext?: boolean;
|
||||
maxVisiblePages?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface VideoThumbnailProps {
|
||||
fileId: string;
|
||||
timestamp?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// Error types
|
||||
export interface VideoError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
// Loading states
|
||||
export type LoadingState = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
// Hook return types are exported from their respective hook files
|
||||
// This avoids circular import issues
|
||||
9
web/src/features/video-streaming/utils/index.ts
Normal file
9
web/src/features/video-streaming/utils/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Video Streaming Utils - Index
|
||||
*
|
||||
* Centralized export for all video streaming utilities.
|
||||
*/
|
||||
|
||||
export * from './videoUtils';
|
||||
export * from './thumbnailCache';
|
||||
export * from './performanceMonitor';
|
||||
197
web/src/features/video-streaming/utils/performanceMonitor.ts
Normal file
197
web/src/features/video-streaming/utils/performanceMonitor.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Performance Monitor for Video Streaming
|
||||
*
|
||||
* Tracks and reports performance metrics for video streaming operations.
|
||||
*/
|
||||
|
||||
interface PerformanceMetric {
|
||||
operation: string;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PerformanceStats {
|
||||
totalOperations: number;
|
||||
successfulOperations: number;
|
||||
failedOperations: number;
|
||||
averageDuration: number;
|
||||
minDuration: number;
|
||||
maxDuration: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export class PerformanceMonitor {
|
||||
private metrics: PerformanceMetric[] = [];
|
||||
private maxMetrics: number;
|
||||
|
||||
constructor(maxMetrics: number = 1000) {
|
||||
this.maxMetrics = maxMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking an operation
|
||||
*/
|
||||
startOperation(operation: string, metadata?: Record<string, any>): string {
|
||||
const id = `${operation}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const metric: PerformanceMetric = {
|
||||
operation,
|
||||
startTime: performance.now(),
|
||||
success: false,
|
||||
metadata,
|
||||
};
|
||||
|
||||
this.metrics.push(metric);
|
||||
|
||||
// Keep only the most recent metrics
|
||||
if (this.metrics.length > this.maxMetrics) {
|
||||
this.metrics.shift();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* End tracking an operation
|
||||
*/
|
||||
endOperation(operation: string, success: boolean, error?: string): void {
|
||||
const metric = this.metrics
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(m => m.operation === operation && !m.endTime);
|
||||
|
||||
if (metric) {
|
||||
metric.endTime = performance.now();
|
||||
metric.duration = metric.endTime - metric.startTime;
|
||||
metric.success = success;
|
||||
metric.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a complete operation
|
||||
*/
|
||||
async trackOperation<T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<T> {
|
||||
this.startOperation(operation, metadata);
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
this.endOperation(operation, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.endOperation(operation, false, error instanceof Error ? error.message : 'Unknown error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics for a specific operation
|
||||
*/
|
||||
getStats(operation?: string): PerformanceStats {
|
||||
const filteredMetrics = operation
|
||||
? this.metrics.filter(m => m.operation === operation && m.duration !== undefined)
|
||||
: this.metrics.filter(m => m.duration !== undefined);
|
||||
|
||||
if (filteredMetrics.length === 0) {
|
||||
return {
|
||||
totalOperations: 0,
|
||||
successfulOperations: 0,
|
||||
failedOperations: 0,
|
||||
averageDuration: 0,
|
||||
minDuration: 0,
|
||||
maxDuration: 0,
|
||||
successRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const durations = filteredMetrics.map(m => m.duration!);
|
||||
const successfulOps = filteredMetrics.filter(m => m.success).length;
|
||||
const failedOps = filteredMetrics.length - successfulOps;
|
||||
|
||||
return {
|
||||
totalOperations: filteredMetrics.length,
|
||||
successfulOperations: successfulOps,
|
||||
failedOperations: failedOps,
|
||||
averageDuration: durations.reduce((sum, d) => sum + d, 0) / durations.length,
|
||||
minDuration: Math.min(...durations),
|
||||
maxDuration: Math.max(...durations),
|
||||
successRate: successfulOps / filteredMetrics.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent metrics
|
||||
*/
|
||||
getRecentMetrics(count: number = 10): PerformanceMetric[] {
|
||||
return this.metrics
|
||||
.filter(m => m.duration !== undefined)
|
||||
.slice(-count)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all metrics
|
||||
*/
|
||||
clear(): void {
|
||||
this.metrics = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export metrics for analysis
|
||||
*/
|
||||
exportMetrics(): PerformanceMetric[] {
|
||||
return [...this.metrics];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a performance report
|
||||
*/
|
||||
getReport(): string {
|
||||
const operations = [...new Set(this.metrics.map(m => m.operation))];
|
||||
let report = 'Video Streaming Performance Report\n';
|
||||
report += '=====================================\n\n';
|
||||
|
||||
for (const operation of operations) {
|
||||
const stats = this.getStats(operation);
|
||||
report += `${operation}:\n`;
|
||||
report += ` Total Operations: ${stats.totalOperations}\n`;
|
||||
report += ` Success Rate: ${(stats.successRate * 100).toFixed(1)}%\n`;
|
||||
report += ` Average Duration: ${stats.averageDuration.toFixed(2)}ms\n`;
|
||||
report += ` Min Duration: ${stats.minDuration.toFixed(2)}ms\n`;
|
||||
report += ` Max Duration: ${stats.maxDuration.toFixed(2)}ms\n\n`;
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
// Helper functions for common operations
|
||||
export const trackVideoLoad = (fileId: string) =>
|
||||
performanceMonitor.startOperation('video_load', { fileId });
|
||||
|
||||
export const trackThumbnailLoad = (fileId: string, width: number, height: number) =>
|
||||
performanceMonitor.startOperation('thumbnail_load', { fileId, width, height });
|
||||
|
||||
export const trackApiCall = (endpoint: string) =>
|
||||
performanceMonitor.startOperation('api_call', { endpoint });
|
||||
|
||||
// Log performance stats periodically in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
setInterval(() => {
|
||||
const stats = performanceMonitor.getStats();
|
||||
if (stats.totalOperations > 0) {
|
||||
console.log('Video Streaming Performance:', stats);
|
||||
}
|
||||
}, 60000); // Every minute
|
||||
}
|
||||
224
web/src/features/video-streaming/utils/thumbnailCache.ts
Normal file
224
web/src/features/video-streaming/utils/thumbnailCache.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Thumbnail Cache Utility
|
||||
*
|
||||
* Provides efficient caching for video thumbnails to improve performance
|
||||
* and reduce API calls.
|
||||
*/
|
||||
|
||||
interface CacheEntry {
|
||||
blob: Blob;
|
||||
url: string;
|
||||
timestamp: number;
|
||||
accessCount: number;
|
||||
lastAccessed: number;
|
||||
}
|
||||
|
||||
interface ThumbnailCacheOptions {
|
||||
maxSize: number; // Maximum number of cached thumbnails
|
||||
maxAge: number; // Maximum age in milliseconds
|
||||
maxMemory: number; // Maximum memory usage in bytes
|
||||
}
|
||||
|
||||
export class ThumbnailCache {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private options: ThumbnailCacheOptions;
|
||||
|
||||
constructor(options: Partial<ThumbnailCacheOptions> = {}) {
|
||||
this.options = {
|
||||
maxSize: options.maxSize || 100,
|
||||
maxAge: options.maxAge || 30 * 60 * 1000, // 30 minutes
|
||||
maxMemory: options.maxMemory || 50 * 1024 * 1024, // 50MB
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for a thumbnail
|
||||
*/
|
||||
private generateKey(fileId: string, timestamp: number, width: number, height: number): string {
|
||||
return `${fileId}_${timestamp}_${width}x${height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail from cache
|
||||
*/
|
||||
get(fileId: string, timestamp: number, width: number, height: number): string | null {
|
||||
const key = this.generateKey(fileId, timestamp, width, height);
|
||||
const entry = this.cache.get(key);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if entry is expired
|
||||
const now = Date.now();
|
||||
if (now - entry.timestamp > this.options.maxAge) {
|
||||
this.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update access statistics
|
||||
entry.accessCount++;
|
||||
entry.lastAccessed = now;
|
||||
|
||||
return entry.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store thumbnail in cache
|
||||
*/
|
||||
set(fileId: string, timestamp: number, width: number, height: number, blob: Blob): string {
|
||||
const key = this.generateKey(fileId, timestamp, width, height);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const now = Date.now();
|
||||
|
||||
// Clean up existing entry if it exists
|
||||
const existingEntry = this.cache.get(key);
|
||||
if (existingEntry) {
|
||||
URL.revokeObjectURL(existingEntry.url);
|
||||
}
|
||||
|
||||
// Create new entry
|
||||
const entry: CacheEntry = {
|
||||
blob,
|
||||
url,
|
||||
timestamp: now,
|
||||
accessCount: 1,
|
||||
lastAccessed: now,
|
||||
};
|
||||
|
||||
this.cache.set(key, entry);
|
||||
|
||||
// Cleanup if necessary
|
||||
this.cleanup();
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific entry from cache
|
||||
*/
|
||||
delete(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) {
|
||||
URL.revokeObjectURL(entry.url);
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached thumbnails
|
||||
*/
|
||||
clear(): void {
|
||||
for (const entry of this.cache.values()) {
|
||||
URL.revokeObjectURL(entry.url);
|
||||
}
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getStats() {
|
||||
const entries = Array.from(this.cache.values());
|
||||
const totalSize = entries.reduce((sum, entry) => sum + entry.blob.size, 0);
|
||||
const totalAccess = entries.reduce((sum, entry) => sum + entry.accessCount, 0);
|
||||
|
||||
return {
|
||||
size: this.cache.size,
|
||||
totalMemory: totalSize,
|
||||
totalAccess,
|
||||
averageSize: entries.length > 0 ? totalSize / entries.length : 0,
|
||||
averageAccess: entries.length > 0 ? totalAccess / entries.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired and least used entries
|
||||
*/
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
const entries = Array.from(this.cache.entries());
|
||||
|
||||
// Remove expired entries
|
||||
for (const [key, entry] of entries) {
|
||||
if (now - entry.timestamp > this.options.maxAge) {
|
||||
this.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to remove more entries
|
||||
if (this.cache.size <= this.options.maxSize) {
|
||||
const stats = this.getStats();
|
||||
if (stats.totalMemory <= this.options.maxMemory) {
|
||||
return; // No cleanup needed
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by access frequency and recency (LRU with access count)
|
||||
const sortedEntries = Array.from(this.cache.entries()).sort(([, a], [, b]) => {
|
||||
// Prioritize by access count, then by last accessed time
|
||||
const scoreA = a.accessCount * 1000 + (a.lastAccessed / 1000);
|
||||
const scoreB = b.accessCount * 1000 + (b.lastAccessed / 1000);
|
||||
return scoreA - scoreB; // Ascending order (least valuable first)
|
||||
});
|
||||
|
||||
// Remove least valuable entries until we're under limits
|
||||
while (
|
||||
(this.cache.size > this.options.maxSize ||
|
||||
this.getStats().totalMemory > this.options.maxMemory) &&
|
||||
sortedEntries.length > 0
|
||||
) {
|
||||
const [key] = sortedEntries.shift()!;
|
||||
this.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload thumbnails for a list of videos
|
||||
*/
|
||||
async preload(
|
||||
videos: Array<{ file_id: string }>,
|
||||
getThumbnailBlob: (fileId: string, params: any) => Promise<Blob>,
|
||||
options: { timestamp?: number; width?: number; height?: number } = {}
|
||||
): Promise<void> {
|
||||
const { timestamp = 1.0, width = 320, height = 240 } = options;
|
||||
|
||||
const promises = videos.slice(0, 10).map(async (video) => {
|
||||
const key = this.generateKey(video.file_id, timestamp, width, height);
|
||||
|
||||
// Skip if already cached
|
||||
if (this.cache.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await getThumbnailBlob(video.file_id, {
|
||||
timestamp,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
this.set(video.file_id, timestamp, width, height, blob);
|
||||
} catch (error) {
|
||||
// Silently fail for preloading
|
||||
console.warn(`Failed to preload thumbnail for ${video.file_id}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const thumbnailCache = new ThumbnailCache({
|
||||
maxSize: 100,
|
||||
maxAge: 30 * 60 * 1000, // 30 minutes
|
||||
maxMemory: 50 * 1024 * 1024, // 50MB
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
thumbnailCache.clear();
|
||||
});
|
||||
}
|
||||
295
web/src/features/video-streaming/utils/videoUtils.ts
Normal file
295
web/src/features/video-streaming/utils/videoUtils.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Video Streaming Utilities
|
||||
*
|
||||
* Pure utility functions for video operations, formatting, and data processing.
|
||||
* These functions have no side effects and can be easily tested.
|
||||
* Enhanced with MP4 format support and improved file handling.
|
||||
*/
|
||||
|
||||
import { type VideoFile, type VideoWithMetadata } from '../types';
|
||||
import {
|
||||
isVideoFile as isVideoFileUtil,
|
||||
getVideoMimeType as getVideoMimeTypeUtil,
|
||||
getVideoFormat,
|
||||
isWebCompatibleFormat,
|
||||
getFormatDisplayName as getFormatDisplayNameUtil
|
||||
} from '../../../utils/videoFileUtils';
|
||||
|
||||
/**
|
||||
* Format file size in bytes to human readable format
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to human readable format (HH:MM:SS or MM:SS)
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds < 0) return '00:00';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date string to human readable format
|
||||
*/
|
||||
export function formatVideoDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago")
|
||||
*/
|
||||
export function getRelativeTime(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
|
||||
return formatVideoDate(dateString);
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename is a video file (supports MP4, AVI, and other formats)
|
||||
*/
|
||||
export function isVideoFile(filename: string): boolean {
|
||||
return isVideoFileUtil(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for video file based on filename
|
||||
*/
|
||||
export function getVideoMimeType(filename: string): string {
|
||||
return getVideoMimeTypeUtil(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract camera name from filename if not provided
|
||||
*/
|
||||
export function extractCameraName(filename: string): string {
|
||||
// Try to extract camera name from filename pattern like "camera1_recording_20250804_143022.avi"
|
||||
const match = filename.match(/^([^_]+)_/);
|
||||
return match ? match[1] : 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video format display name
|
||||
*/
|
||||
export function getFormatDisplayName(format: string): string {
|
||||
return getFormatDisplayNameUtil(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if video format is web-compatible
|
||||
*/
|
||||
export function isWebCompatible(format: string): boolean {
|
||||
return isWebCompatibleFormat(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge color class
|
||||
*/
|
||||
export function getStatusBadgeClass(status: VideoFile['status']): string {
|
||||
const statusClasses = {
|
||||
'completed': 'bg-green-100 text-green-800',
|
||||
'processing': 'bg-yellow-100 text-yellow-800',
|
||||
'failed': 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
return statusClasses[status] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video resolution display string
|
||||
*/
|
||||
export function getResolutionString(width?: number, height?: number): string {
|
||||
if (!width || !height) return 'Unknown';
|
||||
|
||||
// Common resolution names
|
||||
const resolutions: Record<string, string> = {
|
||||
'1920x1080': '1080p',
|
||||
'1280x720': '720p',
|
||||
'854x480': '480p',
|
||||
'640x360': '360p',
|
||||
'426x240': '240p',
|
||||
};
|
||||
|
||||
const key = `${width}x${height}`;
|
||||
return resolutions[key] || `${width}×${height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate aspect ratio string
|
||||
*/
|
||||
export function getAspectRatioString(aspectRatio: number): string {
|
||||
if (!aspectRatio || aspectRatio <= 0) return 'Unknown';
|
||||
|
||||
// Common aspect ratios
|
||||
const ratios: Array<[number, string]> = [
|
||||
[16/9, '16:9'],
|
||||
[4/3, '4:3'],
|
||||
[21/9, '21:9'],
|
||||
[1, '1:1'],
|
||||
];
|
||||
|
||||
// Find closest match (within 0.1 tolerance)
|
||||
for (const [ratio, display] of ratios) {
|
||||
if (Math.abs(aspectRatio - ratio) < 0.1) {
|
||||
return display;
|
||||
}
|
||||
}
|
||||
|
||||
// Return calculated ratio
|
||||
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
|
||||
const width = Math.round(aspectRatio * 100);
|
||||
const height = 100;
|
||||
const divisor = gcd(width, height);
|
||||
|
||||
return `${width / divisor}:${height / divisor}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort videos by different criteria
|
||||
*/
|
||||
export function sortVideos(
|
||||
videos: VideoFile[],
|
||||
field: 'created_at' | 'file_size_bytes' | 'camera_name' | 'filename',
|
||||
direction: 'asc' | 'desc' = 'desc'
|
||||
): VideoFile[] {
|
||||
return [...videos].sort((a, b) => {
|
||||
let aValue: any = a[field];
|
||||
let bValue: any = b[field];
|
||||
|
||||
// Handle date strings
|
||||
if (field === 'created_at') {
|
||||
aValue = new Date(aValue).getTime();
|
||||
bValue = new Date(bValue).getTime();
|
||||
}
|
||||
|
||||
// Handle string comparison
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
if (aValue < bValue) result = -1;
|
||||
else if (aValue > bValue) result = 1;
|
||||
|
||||
return direction === 'desc' ? -result : result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter videos by criteria
|
||||
*/
|
||||
export function filterVideos(
|
||||
videos: VideoFile[],
|
||||
filters: {
|
||||
cameraName?: string;
|
||||
status?: VideoFile['status'];
|
||||
format?: string;
|
||||
dateRange?: { start: string; end: string };
|
||||
}
|
||||
): VideoFile[] {
|
||||
return videos.filter(video => {
|
||||
// Filter by camera name
|
||||
if (filters.cameraName && video.camera_name !== filters.cameraName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (filters.status && video.status !== filters.status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by format
|
||||
if (filters.format && video.format !== filters.format) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (filters.dateRange) {
|
||||
const videoDate = new Date(video.created_at);
|
||||
const startDate = new Date(filters.dateRange.start);
|
||||
const endDate = new Date(filters.dateRange.end);
|
||||
|
||||
if (videoDate < startDate || videoDate > endDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for video caching
|
||||
*/
|
||||
export function generateVideoKey(fileId: string, params?: Record<string, any>): string {
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return fileId;
|
||||
}
|
||||
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.map(key => `${key}=${params[key]}`)
|
||||
.join('&');
|
||||
|
||||
return `${fileId}?${sortedParams}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate video file ID format
|
||||
*/
|
||||
export function isValidFileId(fileId: string): boolean {
|
||||
// Basic validation - adjust based on your file ID format
|
||||
return typeof fileId === 'string' && fileId.length > 0 && !fileId.includes('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video thumbnail timestamp suggestions
|
||||
*/
|
||||
export function getThumbnailTimestamps(duration: number): number[] {
|
||||
if (duration <= 0) return [0];
|
||||
|
||||
// Generate timestamps at 10%, 25%, 50%, 75%, 90% of video duration
|
||||
return [
|
||||
Math.floor(duration * 0.1),
|
||||
Math.floor(duration * 0.25),
|
||||
Math.floor(duration * 0.5),
|
||||
Math.floor(duration * 0.75),
|
||||
Math.floor(duration * 0.9),
|
||||
].filter(t => t >= 0 && t < duration);
|
||||
}
|
||||
48
web/src/hooks/useAuth.ts
Normal file
48
web/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { userManagement, type User } from '../lib/supabase'
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const currentUser = await userManagement.getCurrentUser()
|
||||
setUser(currentUser)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load user')
|
||||
setUser(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = () => {
|
||||
return user?.roles.includes('admin') ?? false
|
||||
}
|
||||
|
||||
const hasRole = (role: string) => {
|
||||
return user?.roles.includes(role as any) ?? false
|
||||
}
|
||||
|
||||
const hasAnyRole = (roles: string[]) => {
|
||||
return roles.some(role => user?.roles.includes(role as any)) ?? false
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
isAdmin,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
refreshUser: loadUser
|
||||
}
|
||||
}
|
||||
81
web/src/hooks/useAutoRecording.ts
Normal file
81
web/src/hooks/useAutoRecording.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* React hook for managing auto-recording functionality
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { autoRecordingManager, type AutoRecordingState } from '../lib/autoRecordingManager'
|
||||
|
||||
export interface UseAutoRecordingResult {
|
||||
isRunning: boolean
|
||||
states: AutoRecordingState[]
|
||||
error: string | null
|
||||
start: () => Promise<void>
|
||||
stop: () => void
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useAutoRecording(): UseAutoRecordingResult {
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [states, setStates] = useState<AutoRecordingState[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Update states periodically
|
||||
useEffect(() => {
|
||||
if (!isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setStates(autoRecordingManager.getStates())
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isRunning])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
await autoRecordingManager.start()
|
||||
setIsRunning(true)
|
||||
setStates(autoRecordingManager.getStates())
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to start auto-recording'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to start auto-recording:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
try {
|
||||
autoRecordingManager.stop()
|
||||
setIsRunning(false)
|
||||
setStates([])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to stop auto-recording'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to stop auto-recording:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
await autoRecordingManager.refreshConfigurations()
|
||||
setStates(autoRecordingManager.getStates())
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to refresh configurations'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to refresh auto-recording configurations:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
states,
|
||||
error,
|
||||
start,
|
||||
stop,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
290
web/src/index.css
Normal file
290
web/src/index.css
Normal file
@@ -0,0 +1,290 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap") layer(base);
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-*: initial;
|
||||
--font-outfit: Outfit, sans-serif;
|
||||
|
||||
--breakpoint-*: initial;
|
||||
--breakpoint-2xsm: 375px;
|
||||
--breakpoint-xsm: 425px;
|
||||
--breakpoint-3xl: 2000px;
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
--breakpoint-2xl: 1536px;
|
||||
|
||||
--text-title-2xl: 72px;
|
||||
--text-title-2xl--line-height: 90px;
|
||||
--text-title-xl: 60px;
|
||||
--text-title-xl--line-height: 72px;
|
||||
--text-title-lg: 48px;
|
||||
--text-title-lg--line-height: 60px;
|
||||
--text-title-md: 36px;
|
||||
--text-title-md--line-height: 44px;
|
||||
--text-title-sm: 30px;
|
||||
--text-title-sm--line-height: 38px;
|
||||
--text-theme-xl: 20px;
|
||||
--text-theme-xl--line-height: 30px;
|
||||
--text-theme-sm: 14px;
|
||||
--text-theme-sm--line-height: 20px;
|
||||
--text-theme-xs: 12px;
|
||||
--text-theme-xs--line-height: 18px;
|
||||
|
||||
--color-current: currentColor;
|
||||
--color-transparent: transparent;
|
||||
--color-white: #ffffff;
|
||||
--color-black: #101828;
|
||||
|
||||
--color-brand-25: #f2f7ff;
|
||||
--color-brand-50: #ecf3ff;
|
||||
--color-brand-100: #dde9ff;
|
||||
--color-brand-200: #c2d6ff;
|
||||
--color-brand-300: #9cb9ff;
|
||||
--color-brand-400: #7592ff;
|
||||
--color-brand-500: #465fff;
|
||||
--color-brand-600: #3641f5;
|
||||
--color-brand-700: #2a31d8;
|
||||
--color-brand-800: #252dae;
|
||||
--color-brand-900: #262e89;
|
||||
--color-brand-950: #161950;
|
||||
|
||||
--color-blue-light-25: #f5fbff;
|
||||
--color-blue-light-50: #f0f9ff;
|
||||
--color-blue-light-100: #e0f2fe;
|
||||
--color-blue-light-200: #b9e6fe;
|
||||
--color-blue-light-300: #7cd4fd;
|
||||
--color-blue-light-400: #36bffa;
|
||||
--color-blue-light-500: #0ba5ec;
|
||||
--color-blue-light-600: #0086c9;
|
||||
--color-blue-light-700: #026aa2;
|
||||
--color-blue-light-800: #065986;
|
||||
--color-blue-light-900: #0b4a6f;
|
||||
--color-blue-light-950: #062c41;
|
||||
|
||||
--color-gray-25: #fcfcfd;
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f2f4f7;
|
||||
--color-gray-200: #e4e7ec;
|
||||
--color-gray-300: #d0d5dd;
|
||||
--color-gray-400: #98a2b3;
|
||||
--color-gray-500: #667085;
|
||||
--color-gray-600: #475467;
|
||||
--color-gray-700: #344054;
|
||||
--color-gray-800: #1d2939;
|
||||
--color-gray-900: #101828;
|
||||
--color-gray-950: #0c111d;
|
||||
--color-gray-dark: #1a2231;
|
||||
|
||||
--color-orange-25: #fffaf5;
|
||||
--color-orange-50: #fff6ed;
|
||||
--color-orange-100: #ffead5;
|
||||
--color-orange-200: #fddcab;
|
||||
--color-orange-300: #feb273;
|
||||
--color-orange-400: #fd853a;
|
||||
--color-orange-500: #fb6514;
|
||||
--color-orange-600: #ec4a0a;
|
||||
--color-orange-700: #c4320a;
|
||||
--color-orange-800: #9c2a10;
|
||||
--color-orange-900: #7e2410;
|
||||
--color-orange-950: #511c10;
|
||||
|
||||
--color-success-25: #f6fef9;
|
||||
--color-success-50: #ecfdf3;
|
||||
--color-success-100: #d1fadf;
|
||||
--color-success-200: #a6f4c5;
|
||||
--color-success-300: #6ce9a6;
|
||||
--color-success-400: #32d583;
|
||||
--color-success-500: #12b76a;
|
||||
--color-success-600: #039855;
|
||||
--color-success-700: #027a48;
|
||||
--color-success-800: #05603a;
|
||||
--color-success-900: #054f31;
|
||||
--color-success-950: #053321;
|
||||
|
||||
--color-error-25: #fffbfa;
|
||||
--color-error-50: #fef3f2;
|
||||
--color-error-100: #fee4e2;
|
||||
--color-error-200: #fecdca;
|
||||
--color-error-300: #fda29b;
|
||||
--color-error-400: #f97066;
|
||||
--color-error-500: #f04438;
|
||||
--color-error-600: #d92d20;
|
||||
--color-error-700: #b42318;
|
||||
--color-error-800: #912018;
|
||||
--color-error-900: #7a271a;
|
||||
--color-error-950: #55160c;
|
||||
|
||||
--color-warning-25: #fffcf5;
|
||||
--color-warning-50: #fffaeb;
|
||||
--color-warning-100: #fef0c7;
|
||||
--color-warning-200: #fedf89;
|
||||
--color-warning-300: #fec84b;
|
||||
--color-warning-400: #fdb022;
|
||||
--color-warning-500: #f79009;
|
||||
--color-warning-600: #dc6803;
|
||||
--color-warning-700: #b54708;
|
||||
--color-warning-800: #93370d;
|
||||
--color-warning-900: #7a2e0e;
|
||||
--color-warning-950: #4e1d09;
|
||||
|
||||
--color-theme-pink-500: #ee46bc;
|
||||
|
||||
--color-theme-purple-500: #7a5af8;
|
||||
|
||||
--shadow-theme-md: 0px 4px 8px -2px rgba(16, 24, 40, 0.1),
|
||||
0px 2px 4px -2px rgba(16, 24, 40, 0.06);
|
||||
--shadow-theme-lg: 0px 12px 16px -4px rgba(16, 24, 40, 0.08),
|
||||
0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
--shadow-theme-sm: 0px 1px 3px 0px rgba(16, 24, 40, 0.1),
|
||||
0px 1px 2px 0px rgba(16, 24, 40, 0.06);
|
||||
--shadow-theme-xs: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
|
||||
--shadow-theme-xl: 0px 20px 24px -4px rgba(16, 24, 40, 0.08),
|
||||
0px 8px 8px -4px rgba(16, 24, 40, 0.03);
|
||||
--shadow-datepicker: -5px 0 0 #262d3c, 5px 0 0 #262d3c;
|
||||
--shadow-focus-ring: 0px 0px 0px 4px rgba(70, 95, 255, 0.12);
|
||||
--shadow-slider-navigation: 0px 1px 2px 0px rgba(16, 24, 40, 0.1),
|
||||
0px 1px 3px 0px rgba(16, 24, 40, 0.1);
|
||||
--shadow-tooltip: 0px 4px 6px -2px rgba(16, 24, 40, 0.05),
|
||||
-8px 0px 20px 8px rgba(16, 24, 40, 0.05);
|
||||
|
||||
--drop-shadow-4xl: 0 35px 35px rgba(0, 0, 0, 0.25),
|
||||
0 45px 65px rgba(0, 0, 0, 0.15);
|
||||
|
||||
--z-index-1: 1;
|
||||
--z-index-9: 9;
|
||||
--z-index-99: 99;
|
||||
--z-index-999: 999;
|
||||
--z-index-9999: 9999;
|
||||
--z-index-99999: 99999;
|
||||
--z-index-999999: 999999;
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply relative font-normal font-outfit z-1 bg-gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
@utility menu-item {
|
||||
@apply relative flex items-center w-full gap-3 px-3 py-2 font-medium rounded-lg text-theme-sm;
|
||||
}
|
||||
|
||||
@utility menu-item-active {
|
||||
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-inactive {
|
||||
@apply text-gray-700 hover:bg-gray-100 group-hover:text-gray-700 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-item-icon {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400;
|
||||
}
|
||||
|
||||
@utility menu-item-icon-active {
|
||||
@apply text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-icon-size {
|
||||
& svg {
|
||||
@apply !size-6;
|
||||
}
|
||||
}
|
||||
|
||||
@utility menu-item-icon-inactive {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow-active {
|
||||
@apply rotate-180 text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow-inactive {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item {
|
||||
@apply relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-theme-sm font-medium;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item-active {
|
||||
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item-inactive {
|
||||
@apply text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge {
|
||||
@apply block rounded-full px-2.5 py-0.5 text-xs font-medium uppercase text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge-active {
|
||||
@apply bg-brand-100 dark:bg-brand-500/20;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge-inactive {
|
||||
@apply bg-brand-50 group-hover:bg-brand-100 dark:bg-brand-500/15 dark:group-hover:bg-brand-500/20;
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
|
||||
/* Chrome, Safari and Opera */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
}
|
||||
|
||||
@utility custom-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
@apply size-1.5;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-200 rounded-full dark:bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #344054;
|
||||
}
|
||||
286
web/src/lib/autoRecordingManager.ts
Normal file
286
web/src/lib/autoRecordingManager.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Auto-Recording Manager
|
||||
*
|
||||
* This module handles automatic recording start/stop based on MQTT machine state changes.
|
||||
* It monitors MQTT events and triggers camera recording when machines turn on/off.
|
||||
*/
|
||||
|
||||
import { visionApi, type MqttEvent, type CameraConfig } from './visionApi'
|
||||
|
||||
export interface AutoRecordingState {
|
||||
cameraName: string
|
||||
machineState: 'on' | 'off'
|
||||
isRecording: boolean
|
||||
autoRecordEnabled: boolean
|
||||
lastStateChange: Date
|
||||
}
|
||||
|
||||
export class AutoRecordingManager {
|
||||
private cameras: Map<string, AutoRecordingState> = new Map()
|
||||
private mqttPollingInterval: NodeJS.Timeout | null = null
|
||||
private lastProcessedEventNumber = 0
|
||||
private isRunning = false
|
||||
|
||||
constructor(private pollingIntervalMs: number = 2000) {}
|
||||
|
||||
/**
|
||||
* Start the auto-recording manager
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
console.warn('Auto-recording manager is already running')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Starting auto-recording manager...')
|
||||
this.isRunning = true
|
||||
|
||||
// Initialize camera configurations
|
||||
await this.initializeCameras()
|
||||
|
||||
// Start polling for MQTT events
|
||||
this.startMqttPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the auto-recording manager
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Stopping auto-recording manager...')
|
||||
this.isRunning = false
|
||||
|
||||
if (this.mqttPollingInterval) {
|
||||
clearInterval(this.mqttPollingInterval)
|
||||
this.mqttPollingInterval = null
|
||||
}
|
||||
|
||||
this.cameras.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize camera configurations and states
|
||||
*/
|
||||
private async initializeCameras(): Promise<void> {
|
||||
try {
|
||||
const cameras = await visionApi.getCameras()
|
||||
|
||||
for (const [cameraName, cameraStatus] of Object.entries(cameras)) {
|
||||
try {
|
||||
const config = await visionApi.getCameraConfig(cameraName)
|
||||
|
||||
this.cameras.set(cameraName, {
|
||||
cameraName,
|
||||
machineState: 'off', // Default to off
|
||||
isRecording: cameraStatus.is_recording,
|
||||
autoRecordEnabled: config.auto_record_on_machine_start,
|
||||
lastStateChange: new Date()
|
||||
})
|
||||
|
||||
console.log(`Initialized camera ${cameraName}: auto-record=${config.auto_record_on_machine_start}, machine=${config.machine_topic}`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize camera ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize cameras:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for MQTT events
|
||||
*/
|
||||
private startMqttPolling(): void {
|
||||
this.mqttPollingInterval = setInterval(async () => {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.processMqttEvents()
|
||||
} catch (error) {
|
||||
console.error('Error processing MQTT events:', error)
|
||||
}
|
||||
}, this.pollingIntervalMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process new MQTT events and trigger recording actions
|
||||
*/
|
||||
private async processMqttEvents(): Promise<void> {
|
||||
try {
|
||||
const mqttResponse = await visionApi.getMqttEvents(50) // Get recent events
|
||||
|
||||
// Filter for new events we haven't processed yet
|
||||
const newEvents = mqttResponse.events.filter(
|
||||
event => event.message_number > this.lastProcessedEventNumber
|
||||
)
|
||||
|
||||
if (newEvents.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update last processed event number
|
||||
this.lastProcessedEventNumber = Math.max(
|
||||
...newEvents.map(event => event.message_number)
|
||||
)
|
||||
|
||||
// Process each new event
|
||||
for (const event of newEvents) {
|
||||
await this.handleMqttEvent(event)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MQTT events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single MQTT event and trigger recording if needed
|
||||
*/
|
||||
private async handleMqttEvent(event: MqttEvent): Promise<void> {
|
||||
const { machine_name, normalized_state } = event
|
||||
|
||||
// Find cameras that are configured for this machine
|
||||
const affectedCameras = await this.getCamerasForMachine(machine_name)
|
||||
|
||||
for (const cameraName of affectedCameras) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
|
||||
if (!cameraState || !cameraState.autoRecordEnabled) {
|
||||
continue
|
||||
}
|
||||
|
||||
const newMachineState = normalized_state as 'on' | 'off'
|
||||
|
||||
// Skip if state hasn't changed
|
||||
if (cameraState.machineState === newMachineState) {
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`Machine ${machine_name} changed from ${cameraState.machineState} to ${newMachineState} - Camera: ${cameraName}`)
|
||||
|
||||
// Update camera state
|
||||
cameraState.machineState = newMachineState
|
||||
cameraState.lastStateChange = new Date()
|
||||
|
||||
// Trigger recording action
|
||||
if (newMachineState === 'on' && !cameraState.isRecording) {
|
||||
await this.startAutoRecording(cameraName, machine_name)
|
||||
} else if (newMachineState === 'off' && cameraState.isRecording) {
|
||||
await this.stopAutoRecording(cameraName, machine_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cameras that are configured for a specific machine
|
||||
*/
|
||||
private async getCamerasForMachine(machineName: string): Promise<string[]> {
|
||||
const cameras: string[] = []
|
||||
|
||||
// Define the correct machine-to-camera mapping
|
||||
const machineToCamera: Record<string, string> = {
|
||||
'blower_separator': 'camera1', // camera1 is for blower separator
|
||||
'vibratory_conveyor': 'camera2' // camera2 is for conveyor
|
||||
}
|
||||
|
||||
const expectedCamera = machineToCamera[machineName]
|
||||
if (!expectedCamera) {
|
||||
console.warn(`No camera mapping found for machine: ${machineName}`)
|
||||
return cameras
|
||||
}
|
||||
|
||||
try {
|
||||
const allCameras = await visionApi.getCameras()
|
||||
|
||||
// Check if the expected camera exists and has auto-recording enabled
|
||||
if (allCameras[expectedCamera]) {
|
||||
try {
|
||||
const config = await visionApi.getCameraConfig(expectedCamera)
|
||||
|
||||
if (config.auto_record_on_machine_start) {
|
||||
cameras.push(expectedCamera)
|
||||
console.log(`Found camera ${expectedCamera} configured for machine ${machineName}`)
|
||||
} else {
|
||||
console.log(`Camera ${expectedCamera} exists but auto-recording is disabled`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get config for camera ${expectedCamera}:`, error)
|
||||
}
|
||||
} else {
|
||||
console.warn(`Expected camera ${expectedCamera} not found for machine ${machineName}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get cameras for machine:', error)
|
||||
}
|
||||
|
||||
return cameras
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-recording for a camera
|
||||
*/
|
||||
private async startAutoRecording(cameraName: string, machineName: string): Promise<void> {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `auto_${machineName}_${timestamp}.mp4`
|
||||
|
||||
const result = await visionApi.startRecording(cameraName, { filename })
|
||||
|
||||
if (result.success) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
if (cameraState) {
|
||||
cameraState.isRecording = true
|
||||
}
|
||||
|
||||
console.log(`✅ Auto-recording started for ${cameraName}: ${result.filename}`)
|
||||
} else {
|
||||
console.error(`❌ Failed to start auto-recording for ${cameraName}:`, result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error starting auto-recording for ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-recording for a camera
|
||||
*/
|
||||
private async stopAutoRecording(cameraName: string, machineName: string): Promise<void> {
|
||||
try {
|
||||
const result = await visionApi.stopRecording(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
if (cameraState) {
|
||||
cameraState.isRecording = false
|
||||
}
|
||||
|
||||
console.log(`⏹️ Auto-recording stopped for ${cameraName} (${result.duration_seconds}s)`)
|
||||
} else {
|
||||
console.error(`❌ Failed to stop auto-recording for ${cameraName}:`, result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error stopping auto-recording for ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current auto-recording states for all cameras
|
||||
*/
|
||||
getStates(): AutoRecordingState[] {
|
||||
return Array.from(this.cameras.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh camera configurations (call when configs are updated)
|
||||
*/
|
||||
async refreshConfigurations(): Promise<void> {
|
||||
await this.initializeCameras()
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const autoRecordingManager = new AutoRecordingManager()
|
||||
843
web/src/lib/supabase.ts
Normal file
843
web/src/lib/supabase.ts
Normal file
@@ -0,0 +1,843 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// Local development configuration
|
||||
const supabaseUrl = 'http://127.0.0.1:54321'
|
||||
const supabaseAnonKey = '[REDACTED]'
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
|
||||
// Database types for TypeScript
|
||||
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
|
||||
export type UserStatus = 'active' | 'disabled'
|
||||
export type ScheduleStatus = 'pending schedule' | 'scheduled' | 'canceled' | 'aborted'
|
||||
export type ResultsStatus = 'valid' | 'invalid'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
roles: RoleName[]
|
||||
status: UserStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string
|
||||
name: RoleName
|
||||
description: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Experiment {
|
||||
id: string
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
soaking_duration_hr: number
|
||||
air_drying_time_min: number
|
||||
plate_contact_frequency_hz: number
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
results_status: ResultsStatus
|
||||
completion_status: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface CreateExperimentRequest {
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
soaking_duration_hr: number
|
||||
air_drying_time_min: number
|
||||
plate_contact_frequency_hz: number
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateExperimentRequest {
|
||||
experiment_number?: number
|
||||
reps_required?: number
|
||||
soaking_duration_hr?: number
|
||||
air_drying_time_min?: number
|
||||
plate_contact_frequency_hz?: number
|
||||
throughput_rate_pecans_sec?: number
|
||||
crush_amount_in?: number
|
||||
entry_exit_height_diff_in?: number
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
export interface CreateRepetitionRequest {
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
schedule_status?: ScheduleStatus
|
||||
}
|
||||
|
||||
export interface UpdateRepetitionRequest {
|
||||
scheduled_date?: string | null
|
||||
schedule_status?: ScheduleStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
// Data Entry System Interfaces
|
||||
export type PhaseDraftStatus = 'draft' | 'submitted' | 'withdrawn'
|
||||
export type ExperimentPhase = 'pre-soaking' | 'air-drying' | 'cracking' | 'shelling'
|
||||
|
||||
export interface ExperimentPhaseDraft {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_id: string
|
||||
user_id: string
|
||||
phase_name: ExperimentPhase
|
||||
status: PhaseDraftStatus
|
||||
draft_name?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at?: string | null
|
||||
withdrawn_at?: string | null
|
||||
}
|
||||
|
||||
export interface ExperimentRepetition {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
schedule_status: ScheduleStatus
|
||||
completion_status: boolean
|
||||
is_locked: boolean
|
||||
locked_at?: string | null
|
||||
locked_by?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface PecanDiameterMeasurement {
|
||||
id: string
|
||||
phase_data_id: string
|
||||
measurement_number: number
|
||||
diameter_in: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ExperimentPhaseData {
|
||||
id: string
|
||||
phase_draft_id: string
|
||||
phase_name: ExperimentPhase
|
||||
|
||||
// Pre-soaking phase
|
||||
batch_initial_weight_lbs?: number | null
|
||||
initial_shell_moisture_pct?: number | null
|
||||
initial_kernel_moisture_pct?: number | null
|
||||
soaking_start_time?: string | null
|
||||
|
||||
// Air-drying phase
|
||||
airdrying_start_time?: string | null
|
||||
post_soak_weight_lbs?: number | null
|
||||
post_soak_kernel_moisture_pct?: number | null
|
||||
post_soak_shell_moisture_pct?: number | null
|
||||
avg_pecan_diameter_in?: number | null
|
||||
|
||||
// Cracking phase
|
||||
cracking_start_time?: string | null
|
||||
|
||||
// Shelling phase
|
||||
shelling_start_time?: string | null
|
||||
bin_1_weight_lbs?: number | null
|
||||
bin_2_weight_lbs?: number | null
|
||||
bin_3_weight_lbs?: number | null
|
||||
discharge_bin_weight_lbs?: number | null
|
||||
bin_1_full_yield_oz?: number | null
|
||||
bin_2_full_yield_oz?: number | null
|
||||
bin_3_full_yield_oz?: number | null
|
||||
bin_1_half_yield_oz?: number | null
|
||||
bin_2_half_yield_oz?: number | null
|
||||
bin_3_half_yield_oz?: number | null
|
||||
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
// Related data
|
||||
diameter_measurements?: PecanDiameterMeasurement[]
|
||||
}
|
||||
|
||||
export interface CreatePhaseDraftRequest {
|
||||
experiment_id: string
|
||||
repetition_id: string
|
||||
phase_name: ExperimentPhase
|
||||
draft_name?: string
|
||||
status?: PhaseDraftStatus
|
||||
}
|
||||
|
||||
export interface UpdatePhaseDraftRequest {
|
||||
draft_name?: string
|
||||
status?: PhaseDraftStatus
|
||||
}
|
||||
|
||||
export interface CreatePhaseDataRequest {
|
||||
data_entry_id: string
|
||||
phase_name: ExperimentPhase
|
||||
[key: string]: any // For phase-specific data fields
|
||||
}
|
||||
|
||||
export interface UpdatePhaseDataRequest {
|
||||
[key: string]: any // For phase-specific data fields
|
||||
}
|
||||
|
||||
export interface UserRole {
|
||||
id: string
|
||||
user_id: string
|
||||
role_id: string
|
||||
assigned_at: string
|
||||
assigned_by?: string
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string
|
||||
email: string
|
||||
status: UserStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
role_id?: string // Legacy field, will be deprecated
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
email: string
|
||||
roles: RoleName[]
|
||||
tempPassword?: string
|
||||
}
|
||||
|
||||
export interface CreateUserResponse {
|
||||
user_id: string
|
||||
email: string
|
||||
temp_password: string
|
||||
roles: RoleName[]
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
// User management utility functions
|
||||
export const userManagement = {
|
||||
// Get all users with their roles
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
.from('user_profiles')
|
||||
.select(`
|
||||
id,
|
||||
email,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
|
||||
if (profilesError) throw profilesError
|
||||
|
||||
// Get roles for each user
|
||||
const usersWithRoles = await Promise.all(
|
||||
profiles.map(async (profile) => {
|
||||
const { data: userRoles, error: rolesError } = await supabase
|
||||
.from('user_roles')
|
||||
.select(`
|
||||
roles!inner (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('user_id', profile.id)
|
||||
|
||||
if (rolesError) throw rolesError
|
||||
|
||||
return {
|
||||
...profile,
|
||||
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return usersWithRoles
|
||||
},
|
||||
|
||||
// Get all available roles
|
||||
async getAllRoles(): Promise<Role[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('roles')
|
||||
.select('*')
|
||||
.order('name')
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new user with roles
|
||||
async createUser(userData: CreateUserRequest): Promise<CreateUserResponse> {
|
||||
const { data, error } = await supabase.rpc('create_user_with_roles', {
|
||||
user_email: userData.email,
|
||||
role_names: userData.roles,
|
||||
temp_password: userData.tempPassword
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update user status (enable/disable)
|
||||
async updateUserStatus(userId: string, status: UserStatus): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('user_profiles')
|
||||
.update({ status })
|
||||
.eq('id', userId)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Update user roles
|
||||
async updateUserRoles(userId: string, roleNames: RoleName[]): Promise<void> {
|
||||
// First, remove all existing roles for the user
|
||||
const { error: deleteError } = await supabase
|
||||
.from('user_roles')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (deleteError) throw deleteError
|
||||
|
||||
// Get role IDs for the new roles
|
||||
const { data: roles, error: rolesError } = await supabase
|
||||
.from('roles')
|
||||
.select('id, name')
|
||||
.in('name', roleNames)
|
||||
|
||||
if (rolesError) throw rolesError
|
||||
|
||||
// Insert new role assignments
|
||||
const roleAssignments = roles.map(role => ({
|
||||
user_id: userId,
|
||||
role_id: role.id
|
||||
}))
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('user_roles')
|
||||
.insert(roleAssignments)
|
||||
|
||||
if (insertError) throw insertError
|
||||
},
|
||||
|
||||
// Update user email
|
||||
async updateUserEmail(userId: string, email: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('user_profiles')
|
||||
.update({ email })
|
||||
.eq('id', userId)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Get current user with roles
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !authUser) return null
|
||||
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('user_profiles')
|
||||
.select(`
|
||||
id,
|
||||
email,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
.eq('id', authUser.id)
|
||||
.single()
|
||||
|
||||
if (profileError) throw profileError
|
||||
|
||||
const { data: userRoles, error: rolesError } = await supabase
|
||||
.from('user_roles')
|
||||
.select(`
|
||||
roles!inner (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('user_id', authUser.id)
|
||||
|
||||
if (rolesError) throw rolesError
|
||||
|
||||
return {
|
||||
...profile,
|
||||
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Experiment management utility functions
|
||||
export const experimentManagement = {
|
||||
// Get all experiments
|
||||
async getAllExperiments(): Promise<Experiment[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get experiment by ID
|
||||
async getExperimentById(id: string): Promise<Experiment | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new experiment
|
||||
async createExperiment(experimentData: CreateExperimentRequest): Promise<Experiment> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.insert({
|
||||
...experimentData,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update an experiment
|
||||
async updateExperiment(id: string, updates: UpdateExperimentRequest): Promise<Experiment> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Delete an experiment (admin only)
|
||||
async deleteExperiment(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiments')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Update experiment status
|
||||
async updateExperimentStatus(id: string, scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus): Promise<Experiment> {
|
||||
const updates: Partial<UpdateExperimentRequest> = {}
|
||||
if (scheduleStatus) updates.schedule_status = scheduleStatus
|
||||
if (resultsStatus) updates.results_status = resultsStatus
|
||||
|
||||
return this.updateExperiment(id, updates)
|
||||
},
|
||||
|
||||
// Get experiments by status
|
||||
async getExperimentsByStatus(scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus): Promise<Experiment[]> {
|
||||
let query = supabase.from('experiments').select('*')
|
||||
|
||||
if (scheduleStatus) {
|
||||
query = query.eq('schedule_status', scheduleStatus)
|
||||
}
|
||||
if (resultsStatus) {
|
||||
query = query.eq('results_status', resultsStatus)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
|
||||
|
||||
// Check if experiment number is unique
|
||||
async isExperimentNumberUnique(experimentNumber: number, excludeId?: string): Promise<boolean> {
|
||||
let query = supabase
|
||||
.from('experiments')
|
||||
.select('id')
|
||||
.eq('experiment_number', experimentNumber)
|
||||
|
||||
if (excludeId) {
|
||||
query = query.neq('id', excludeId)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) throw error
|
||||
return data.length === 0
|
||||
}
|
||||
}
|
||||
|
||||
// Experiment Repetitions Management
|
||||
export const repetitionManagement = {
|
||||
// Get all repetitions for an experiment
|
||||
async getExperimentRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.select('*')
|
||||
.eq('experiment_id', experimentId)
|
||||
.order('repetition_number', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new repetition
|
||||
async createRepetition(repetitionData: CreateRepetitionRequest): Promise<ExperimentRepetition> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.insert({
|
||||
...repetitionData,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update a repetition
|
||||
async updateRepetition(id: string, updates: UpdateRepetitionRequest): Promise<ExperimentRepetition> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Schedule a repetition
|
||||
async scheduleRepetition(id: string, scheduledDate: string): Promise<ExperimentRepetition> {
|
||||
const updates: UpdateRepetitionRequest = {
|
||||
scheduled_date: scheduledDate,
|
||||
schedule_status: 'scheduled'
|
||||
}
|
||||
|
||||
return this.updateRepetition(id, updates)
|
||||
},
|
||||
|
||||
// Remove repetition schedule
|
||||
async removeRepetitionSchedule(id: string): Promise<ExperimentRepetition> {
|
||||
const updates: UpdateRepetitionRequest = {
|
||||
scheduled_date: null,
|
||||
schedule_status: 'pending schedule'
|
||||
}
|
||||
|
||||
return this.updateRepetition(id, updates)
|
||||
},
|
||||
|
||||
// Delete a repetition
|
||||
async deleteRepetition(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Get repetitions by status
|
||||
async getRepetitionsByStatus(scheduleStatus?: ScheduleStatus): Promise<ExperimentRepetition[]> {
|
||||
let query = supabase.from('experiment_repetitions').select('*')
|
||||
|
||||
if (scheduleStatus) {
|
||||
query = query.eq('schedule_status', scheduleStatus)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get repetitions with experiment details
|
||||
async getRepetitionsWithExperiments(): Promise<(ExperimentRepetition & { experiment: Experiment })[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.select(`
|
||||
*,
|
||||
experiment:experiments(*)
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create all repetitions for an experiment
|
||||
async createAllRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
|
||||
// First get the experiment to know how many reps are required
|
||||
const { data: experiment, error: expError } = await supabase
|
||||
.from('experiments')
|
||||
.select('reps_required')
|
||||
.eq('id', experimentId)
|
||||
.single()
|
||||
|
||||
if (expError) throw expError
|
||||
|
||||
// Create repetitions for each required rep
|
||||
const repetitions: CreateRepetitionRequest[] = []
|
||||
for (let i = 1; i <= experiment.reps_required; i++) {
|
||||
repetitions.push({
|
||||
experiment_id: experimentId,
|
||||
repetition_number: i,
|
||||
schedule_status: 'pending schedule'
|
||||
})
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.insert(repetitions.map(rep => ({
|
||||
...rep,
|
||||
created_by: user.id
|
||||
})))
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Lock a repetition (admin only)
|
||||
async lockRepetition(repetitionId: string): Promise<ExperimentRepetition> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.update({
|
||||
is_locked: true,
|
||||
locked_at: new Date().toISOString(),
|
||||
locked_by: user.id
|
||||
})
|
||||
.eq('id', repetitionId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Unlock a repetition (admin only)
|
||||
async unlockRepetition(repetitionId: string): Promise<ExperimentRepetition> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.update({
|
||||
is_locked: false,
|
||||
locked_at: null,
|
||||
locked_by: null
|
||||
})
|
||||
.eq('id', repetitionId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// Phase Draft Management
|
||||
export const phaseDraftManagement = {
|
||||
// Get all phase drafts for a repetition
|
||||
async getPhaseDraftsForRepetition(repetitionId: string): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get user's phase drafts for a repetition
|
||||
async getUserPhaseDraftsForRepetition(repetitionId: string): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get user's phase drafts for a specific phase and repetition
|
||||
async getUserPhaseDraftsForPhase(repetitionId: string, phase: ExperimentPhase): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.eq('user_id', user.id)
|
||||
.eq('phase_name', phase)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new phase draft
|
||||
async createPhaseDraft(request: CreatePhaseDraftRequest): Promise<ExperimentPhaseDraft> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.insert({
|
||||
...request,
|
||||
user_id: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update a phase draft
|
||||
async updatePhaseDraft(id: string, updates: UpdatePhaseDraftRequest): Promise<ExperimentPhaseDraft> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Delete a phase draft (only drafts)
|
||||
async deletePhaseDraft(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Submit a phase draft (change status from draft to submitted)
|
||||
async submitPhaseDraft(id: string): Promise<ExperimentPhaseDraft> {
|
||||
return this.updatePhaseDraft(id, { status: 'submitted' })
|
||||
},
|
||||
|
||||
// Withdraw a phase draft (change status from submitted to withdrawn)
|
||||
async withdrawPhaseDraft(id: string): Promise<ExperimentPhaseDraft> {
|
||||
return this.updatePhaseDraft(id, { status: 'withdrawn' })
|
||||
},
|
||||
|
||||
// Get phase data for a phase draft
|
||||
async getPhaseDataForDraft(phaseDraftId: string): Promise<ExperimentPhaseData | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_data')
|
||||
.select(`
|
||||
*,
|
||||
diameter_measurements:pecan_diameter_measurements(*)
|
||||
`)
|
||||
.eq('phase_draft_id', phaseDraftId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // No rows found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Create or update phase data for a draft
|
||||
async upsertPhaseData(phaseDraftId: string, phaseData: Partial<ExperimentPhaseData>): Promise<ExperimentPhaseData> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_data')
|
||||
.upsert({
|
||||
phase_draft_id: phaseDraftId,
|
||||
...phaseData
|
||||
}, {
|
||||
onConflict: 'phase_draft_id,phase_name'
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Save diameter measurements
|
||||
async saveDiameterMeasurements(phaseDataId: string, measurements: number[]): Promise<PecanDiameterMeasurement[]> {
|
||||
// First, delete existing measurements
|
||||
await supabase
|
||||
.from('pecan_diameter_measurements')
|
||||
.delete()
|
||||
.eq('phase_data_id', phaseDataId)
|
||||
|
||||
// Then insert new measurements
|
||||
const measurementData = measurements.map((diameter, index) => ({
|
||||
phase_data_id: phaseDataId,
|
||||
measurement_number: index + 1,
|
||||
diameter_in: diameter
|
||||
}))
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('pecan_diameter_measurements')
|
||||
.insert(measurementData)
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Calculate average diameter from measurements
|
||||
calculateAverageDiameter(measurements: number[]): number {
|
||||
if (measurements.length === 0) return 0
|
||||
const validMeasurements = measurements.filter(m => m > 0)
|
||||
if (validMeasurements.length === 0) return 0
|
||||
return validMeasurements.reduce((sum, m) => sum + m, 0) / validMeasurements.length
|
||||
},
|
||||
|
||||
// Auto-save draft data (for periodic saves)
|
||||
async autoSaveDraft(phaseDraftId: string, phaseData: Partial<ExperimentPhaseData>): Promise<void> {
|
||||
try {
|
||||
await this.upsertPhaseData(phaseDraftId, phaseData)
|
||||
} catch (error) {
|
||||
console.warn('Auto-save failed:', error)
|
||||
// Don't throw error for auto-save failures
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user