Update camera management and MQTT logging for improved functionality

- Changed log level in configuration from WARNING to INFO for better visibility of system operations.
- Enhanced StandaloneAutoRecorder initialization to accept camera manager, state manager, and event system for improved modularity.
- Updated recording routes to handle optional request bodies and improved error logging for better debugging.
- Added checks in CameraMonitor to determine if a camera is already in use before initialization, enhancing resource management.
- Improved MQTT client logging to provide more detailed connection and message handling information.
- Added new MQTT event handling capabilities to the VisionApiClient for better tracking of machine states.
This commit is contained in:
salirezav
2025-11-03 16:56:53 -05:00
parent 868aa3f036
commit 4acad772f9
17 changed files with 1074 additions and 83 deletions

251
SESSION_SUMMARY.md Normal file
View File

@@ -0,0 +1,251 @@
# Session Summary: MQTT Auto-Recording Debugging & Theme Implementation
## Overview
This session focused on debugging MQTT-triggered auto-recording functionality and implementing dark/light theme toggle in the management dashboard. Multiple issues were identified and resolved related to camera recording, MQTT message handling, and UI theming.
---
## Major Tasks Completed
### 1. Scheduling Module Extraction (Previous Session)
- Extracted the Scheduling module into a separate microfrontend (`scheduling-remote`)
- Configured Module Federation for the new microfrontend
- Updated `docker-compose.yml` to include the new service
### 2. Dark/Light Theme Toggle Implementation
- Created `useTheme` hook (`management-dashboard-web-app/src/hooks/useTheme.ts`)
- Added theme toggle button to `TopNavbar.tsx`
- Applied dark mode classes across multiple components:
- `DashboardLayout.tsx` - Main content area
- `Login.tsx` - Login page background
- `DataEntry.tsx` - Widgets, text, borders
- `UserManagement.tsx` - Stats cards, tables, forms
- `ExperimentPhases.tsx` - Phase cards, badges
- `index.css` - Body background
- `index.html` - Inline script to prevent FOUC (Flash of Unstyled Content)
### 3. MQTT Auto-Recording Debugging
#### Issues Identified:
1. **Manual recording buttons not working** - "Record" buttons for cameras couldn't start manual recording
2. **Automatic recording unavailable** - MQTT-triggered auto-recording wasn't functioning
3. **Camera2 initialization error** - Error code 32774 when initializing camera2
4. **Button visibility issues** - "Restart Camera" and "Stop Streaming" buttons appeared transparent
#### Fixes Applied:
##### A. Transparent Buttons Fix
**File:** `vision-system-remote/src/components/CameraCard.tsx`
- Added inline `style` attributes to "Restart Camera" and "Stop Streaming" buttons
- Explicitly set `backgroundColor` and `color` to prevent transparency
##### B. Camera Initialization Error (32774)
**File:** `camera-management-api/usda_vision_system/camera/monitor.py`
- Enhanced error handling for camera initialization error code 32774
- Added logic to check if camera is already in use by existing recorder/streamer
- If error 32774 occurs, camera is marked as "available" with warning instead of failing completely
- Provides better diagnostics for cameras that might be in use by another process
##### C. StandaloneAutoRecorder Integration
**File:** `camera-management-api/usda_vision_system/recording/standalone_auto_recorder.py`
- **Problem:** `StandaloneAutoRecorder` was creating its own MQTT client and camera recorders, causing conflicts
- **Solution:**
- Modified constructor to accept `camera_manager`, `state_manager`, and `event_system` instances
- Removed internal MQTT client setup when using shared instances
- Now subscribes to `EventType.MACHINE_STATE_CHANGED` events from the event system
- Uses `camera_manager.manual_start_recording()` and `manual_stop_recording()` instead of creating its own recorders
- Added comprehensive logging for event reception and recording operations
**File:** `camera-management-api/usda_vision_system/main.py`
- Updated `USDAVisionSystem` constructor to pass `camera_manager`, `state_manager`, and `event_system` to `StandaloneAutoRecorder`
##### D. Missing Constants Import
**File:** `camera-management-api/usda_vision_system/camera/recorder.py`
- **Error:** `NameError: name 'CAMERA_TEST_CAPTURE_TIMEOUT' is not defined`
- **Fix:** Added missing imports:
- `CAMERA_GET_BUFFER_TIMEOUT`
- `CAMERA_INIT_TIMEOUT`
- `CAMERA_TEST_CAPTURE_TIMEOUT`
- `DEFAULT_VIDEO_FPS`
- `BRIEF_PAUSE_SLEEP`
- All imported from `.constants`
##### E. Recording Routes Enhancement
**File:** `camera-management-api/usda_vision_system/api/routes/recording_routes.py`
- Made `StartRecordingRequest` parameter optional for `start_recording` endpoint
- Added logic to create default `StartRecordingRequest` if `request` is `None`
- Added INFO-level logging for recording attempts and success/failure
- Improved error messages for `manual_start_recording` failures
##### F. MQTT Logging Improvements
**Files:**
- `camera-management-api/usda_vision_system/mqtt/client.py`
- `camera-management-api/usda_vision_system/mqtt/handlers.py`
**Changes:**
- Changed MQTT message logging from `DEBUG` to `INFO` level for visibility in production
- Added detailed logging for:
- Message reception: `📡 MQTT MESSAGE RECEIVED - Topic: {topic}, Payload: '{payload}'`
- Message processing: `📡 Processing MQTT message for machine '{machine_name}': '{payload}'`
- Payload normalization: `📡 Normalized payload '{payload}' -> '{normalized_payload}'`
- Event publishing: `📡 Publishing MACHINE_STATE_CHANGED event`
- Connection status: `🔗 MQTT CONNECTED` with broker details
- Subscription confirmation: `📋 MQTT subscribed to {count} topics`
##### G. MQTT Test Script
**File:** `camera-management-api/test_mqtt_simple.py` (NEW)
- Created standalone MQTT test script to verify connectivity and message reception
- Connects to MQTT broker and subscribes to configured topics
- Displays received messages in real-time with timestamps
- Validates payload format (on/off/true/false/1/0)
- Shows connection statistics
### 4. Configuration Updates
**File:** `camera-management-api/config.json`
- Confirmed `log_level` is set to `INFO`
- `auto_recording_enabled` is set to `true`
---
## Key Files Modified
### Frontend (React/TypeScript)
1. `management-dashboard-web-app/src/hooks/useTheme.ts` - NEW
2. `management-dashboard-web-app/src/components/TopNavbar.tsx`
3. `management-dashboard-web-app/src/components/DashboardLayout.tsx`
4. `management-dashboard-web-app/src/components/Login.tsx`
5. `management-dashboard-web-app/src/components/DataEntry.tsx`
6. `management-dashboard-web-app/src/components/UserManagement.tsx`
7. `management-dashboard-web-app/src/components/ExperimentPhases.tsx`
8. `management-dashboard-web-app/src/index.css`
9. `management-dashboard-web-app/index.html`
10. `vision-system-remote/src/components/CameraCard.tsx`
### Backend (Python/FastAPI)
1. `camera-management-api/usda_vision_system/recording/standalone_auto_recorder.py`
2. `camera-management-api/usda_vision_system/main.py`
3. `camera-management-api/usda_vision_system/camera/recorder.py`
4. `camera-management-api/usda_vision_system/camera/monitor.py`
5. `camera-management-api/usda_vision_system/api/routes/recording_routes.py`
6. `camera-management-api/usda_vision_system/mqtt/client.py`
7. `camera-management-api/usda_vision_system/mqtt/handlers.py`
8. `camera-management-api/test_mqtt_simple.py` - NEW
---
## Current Status
### ✅ Completed
- Dark/light theme toggle fully implemented
- Transparent buttons fixed
- Camera initialization error handling improved
- `StandaloneAutoRecorder` properly integrated with shared instances
- Missing constants imported
- Recording routes enhanced with better logging
- MQTT logging improved to INFO level
- MQTT test script created
### 🔄 Testing Needed
- **MQTT Auto-Recording**: Needs verification that MQTT messages trigger recording
- Test script available: `camera-management-api/test_mqtt_simple.py`
- Monitor logs for MQTT message flow: `docker compose logs api -f | grep -i "mqtt\|📡"`
- Check MQTT status: `curl http://localhost:8000/mqtt/status`
- Check recent events: `curl http://localhost:8000/mqtt/events?limit=10`
### 📋 Expected MQTT Message Flow
When a machine turns on/off, the following should appear in logs:
1. `📡 MQTT MESSAGE RECEIVED` - Message received from broker
2. `📡 Processing MQTT message` - Message being processed
3. `📡 Normalized payload` - Payload normalization
4. `✅ Published MACHINE_STATE_CHANGED event` - Event published to event system
5. `📡 AUTO-RECORDER: Received MACHINE_STATE_CHANGED event` - Auto-recorder received event
6. `✅ Started auto-recording` - Recording started
---
## MQTT Configuration
From `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"
}
}
}
```
Machine-to-Camera Mapping (from `standalone_auto_recorder.py`):
- `vibratory_conveyor``camera2`
- `blower_separator``camera1`
---
## Testing Commands
### Test MQTT Connectivity
```bash
# Option 1: Standalone test script
docker compose exec api python test_mqtt_simple.py
# Option 2: Check MQTT status via API
curl -s http://localhost:8000/mqtt/status | python -m json.tool
# Option 3: Monitor API logs for MQTT messages
docker compose logs api -f | grep -i "mqtt\|📡"
# Option 4: Check recent MQTT events
curl -s http://localhost:8000/mqtt/events?limit=10 | python -m json.tool
```
### Restart API Container
```bash
docker compose restart api
```
---
## Architecture Notes
### Event Flow for Auto-Recording
1. **MQTT Message Received**`MQTTClient._on_message()`
2. **Message Processed**`MQTTMessageHandler.handle_message()`
3. **State Updated**`StateManager.update_machine_state()`
4. **Event Published**`EventSystem.publish(EventType.MACHINE_STATE_CHANGED)`
5. **Event Received**`StandaloneAutoRecorder._on_machine_state_changed()`
6. **Recording Started/Stopped**`CameraManager.manual_start_recording()` / `manual_stop_recording()`
### Key Components
- **MQTTClient**: Connects to MQTT broker, subscribes to topics, receives messages
- **MQTTMessageHandler**: Processes MQTT messages, normalizes payloads, updates state
- **EventSystem**: Publishes/subscribes to internal events
- **StandaloneAutoRecorder**: Subscribes to `MACHINE_STATE_CHANGED` events, triggers recording
- **CameraManager**: Manages camera operations including recording
---
## Notes for Future Sessions
1. **MQTT Testing**: If auto-recording still doesn't work, verify:
- MQTT broker is reachable from API container
- Topics match exactly (case-sensitive)
- Messages are being published to correct topics
- Payload format is recognized (on/off/true/false/1/0)
2. **Camera Initialization**: Error 32774 typically means camera is in use. The system now handles this gracefully but may need investigation if cameras are not accessible.
3. **Theme Persistence**: Theme preference is stored in `localStorage` and persists across sessions.
4. **Log Level**: Currently set to `INFO` in `config.json` for comprehensive event tracking.
---
## Session Date
Session completed with focus on MQTT debugging and enhanced logging for troubleshooting auto-recording functionality.

View File

@@ -17,7 +17,7 @@
}, },
"system": { "system": {
"camera_check_interval_seconds": 2, "camera_check_interval_seconds": 2,
"log_level": "WARNING", "log_level": "INFO",
"log_file": "usda_vision_system.log", "log_file": "usda_vision_system.log",
"api_host": "0.0.0.0", "api_host": "0.0.0.0",
"api_port": 8000, "api_port": 8000,

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
Simple MQTT Test Script
This script tests MQTT connectivity and message reception.
It connects to the broker and listens for messages on the configured topics.
Usage:
python test_mqtt_simple.py
"""
import paho.mqtt.client as mqtt
import time
import signal
import sys
from datetime import datetime
# MQTT Configuration (from config.json)
MQTT_BROKER_HOST = "192.168.1.110"
MQTT_BROKER_PORT = 1883
MQTT_USERNAME = None
MQTT_PASSWORD = None
# Topics to monitor (from config.json)
MQTT_TOPICS = {
"vibratory_conveyor": "vision/vibratory_conveyor/state",
"blower_separator": "vision/blower_separator/state"
}
class SimpleMQTTTester:
def __init__(self):
self.client = None
self.message_count = 0
self.running = True
def on_connect(self, client, userdata, flags, rc):
"""Callback when client connects"""
if rc == 0:
print(f"✅ Connected to MQTT broker: {MQTT_BROKER_HOST}:{MQTT_BROKER_PORT}")
# Subscribe to all topics
for machine_name, topic in MQTT_TOPICS.items():
result, mid = client.subscribe(topic)
if result == mqtt.MQTT_ERR_SUCCESS:
print(f"📋 Subscribed to: {topic} (machine: {machine_name})")
else:
print(f"❌ Failed to subscribe to {topic}: {result}")
else:
print(f"❌ Connection failed with return code {rc}")
def on_disconnect(self, client, userdata, rc):
"""Callback when client disconnects"""
if rc != 0:
print(f"⚠️ Unexpected disconnection (rc: {rc})")
else:
print("🔌 Disconnected from broker")
def on_message(self, client, userdata, msg):
"""Callback when a message is received"""
try:
topic = msg.topic
payload = msg.payload.decode("utf-8").strip()
timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3]
self.message_count += 1
# Find machine name
machine_name = "unknown"
for name, configured_topic in MQTT_TOPICS.items():
if topic == configured_topic:
machine_name = name
break
# Display message
print(f"\n📡 [{timestamp}] Message #{self.message_count}")
print(f" 🏭 Machine: {machine_name}")
print(f" 📍 Topic: {topic}")
print(f" 📄 Payload: '{payload}'")
print(f" 📊 Total messages received: {self.message_count}")
# Check if payload is valid on/off
payload_lower = payload.lower()
if payload_lower in ["on", "off", "true", "false", "1", "0"]:
state = "ON" if payload_lower in ["on", "true", "1"] else "OFF"
print(f" ✅ Valid state: {state}")
else:
print(f" ⚠️ Unusual payload format: '{payload}'")
print("-" * 60)
except Exception as e:
print(f"❌ Error processing message: {e}")
def start(self):
"""Start the MQTT tester"""
print("🧪 Starting MQTT Test")
print("=" * 60)
print(f"Broker: {MQTT_BROKER_HOST}:{MQTT_BROKER_PORT}")
print(f"Topics to monitor: {len(MQTT_TOPICS)}")
for name, topic in MQTT_TOPICS.items():
print(f" - {name}: {topic}")
print("=" * 60)
print("\nWaiting for messages... (Press Ctrl+C to stop)\n")
# Create client
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1)
self.client.on_connect = self.on_connect
self.client.on_disconnect = self.on_disconnect
self.client.on_message = self.on_message
# Set authentication if provided
if MQTT_USERNAME and MQTT_PASSWORD:
self.client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
# Connect
try:
self.client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, 60)
except Exception as e:
print(f"❌ Failed to connect: {e}")
return False
# Start loop
self.client.loop_start()
# Wait for messages
try:
while self.running:
time.sleep(1)
except KeyboardInterrupt:
print("\n\n🛑 Stopping test...")
self.running = False
# Cleanup
self.client.loop_stop()
self.client.disconnect()
print(f"\n📊 Test Summary:")
print(f" Total messages received: {self.message_count}")
print("✅ Test completed")
return True
def main():
"""Main entry point"""
tester = SimpleMQTTTester()
# Setup signal handler for graceful shutdown
def signal_handler(sig, frame):
print("\n\n🛑 Received interrupt signal, shutting down...")
tester.running = False
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
success = tester.start()
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

View File

@@ -4,6 +4,7 @@ Recording-related API routes.
import logging import logging
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from typing import Optional
from ...camera.manager import CameraManager from ...camera.manager import CameraManager
from ..models import StartRecordingResponse, StopRecordingResponse, StartRecordingRequest from ..models import StartRecordingResponse, StopRecordingResponse, StartRecordingRequest
from ...core.timezone_utils import format_filename_timestamp from ...core.timezone_utils import format_filename_timestamp
@@ -17,12 +18,19 @@ def register_recording_routes(
"""Register recording-related routes""" """Register recording-related routes"""
@app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse) @app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse)
async def start_recording(camera_name: str, request: StartRecordingRequest): async def start_recording(camera_name: str, request: Optional[StartRecordingRequest] = None):
"""Manually start recording for a camera""" """Manually start recording for a camera"""
try: try:
if not camera_manager: if not camera_manager:
logger.error("Camera manager not available")
raise HTTPException(status_code=503, detail="Camera manager not available") raise HTTPException(status_code=503, detail="Camera manager not available")
# Handle case where request body might be None or empty
if request is None:
request = StartRecordingRequest()
logger.info(f"📹 Starting recording for {camera_name} - filename: {request.filename}, exposure_ms: {request.exposure_ms}, gain: {request.gain}, fps: {request.fps}")
success = camera_manager.manual_start_recording( success = camera_manager.manual_start_recording(
camera_name=camera_name, camera_name=camera_name,
filename=request.filename, filename=request.filename,
@@ -37,19 +45,28 @@ def register_recording_routes(
if request.filename: if request.filename:
timestamp = format_filename_timestamp() timestamp = format_filename_timestamp()
actual_filename = f"{timestamp}_{request.filename}" actual_filename = f"{timestamp}_{request.filename}"
else:
timestamp = format_filename_timestamp()
camera_config = camera_manager.get_camera_config(camera_name)
video_format = camera_config.video_format if camera_config else "mp4"
actual_filename = f"{camera_name}_manual_{timestamp}.{video_format}"
logger.info(f"✅ Recording started successfully for {camera_name}: {actual_filename}")
return StartRecordingResponse( return StartRecordingResponse(
success=True, success=True,
message=f"Recording started for {camera_name}", message=f"Recording started for {camera_name}",
filename=actual_filename filename=actual_filename
) )
else: else:
logger.error(f"❌ Failed to start recording for {camera_name} - manual_start_recording returned False")
return StartRecordingResponse( return StartRecordingResponse(
success=False, success=False,
message=f"Failed to start recording for {camera_name}" message=f"Failed to start recording for {camera_name}. Check camera status and logs."
) )
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"Error starting recording: {e}") logger.error(f"Error starting recording for {camera_name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse) @app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse)

View File

@@ -188,6 +188,34 @@ class CameraMonitor:
self.logger.info(f"Attempting to initialize camera {camera_name} for availability test...") self.logger.info(f"Attempting to initialize camera {camera_name} for availability test...")
# Check if camera is already in use by recorder or streamer before trying to initialize
recorder = self.camera_manager.camera_recorders.get(camera_name) if self.camera_manager else None
streamer = self.camera_manager.camera_streamers.get(camera_name) if self.camera_manager else None
camera_in_use = False
if recorder and recorder.hCamera:
try:
# Check if recorder has camera open
if mvsdk.CameraIsOpened(recorder.hCamera):
camera_in_use = True
self.logger.info(f"Camera {camera_name} is already in use by recorder (handle: {recorder.hCamera})")
except:
pass
if not camera_in_use and streamer and streamer.hCamera:
try:
# Check if streamer has camera open
if mvsdk.CameraIsOpened(streamer.hCamera):
camera_in_use = True
self.logger.info(f"Camera {camera_name} is already in use by streamer (handle: {streamer.hCamera})")
except:
pass
# If camera is already in use, mark as available (since it's working, just occupied)
if camera_in_use:
self.logger.info(f"Camera {camera_name} is in use by system components - marking as available")
return "available", "Camera is in use by system", self._get_device_info_dict(device_info)
# Suppress output to avoid MVCAMAPI error messages during camera testing # Suppress output to avoid MVCAMAPI error messages during camera testing
hCamera = None hCamera = None
try: try:
@@ -195,7 +223,26 @@ class CameraMonitor:
hCamera = mvsdk.CameraInit(device_info, -1, -1) hCamera = mvsdk.CameraInit(device_info, -1, -1)
self.logger.info(f"Camera {camera_name} initialized successfully, starting test capture...") self.logger.info(f"Camera {camera_name} initialized successfully, starting test capture...")
except mvsdk.CameraException as init_e: except mvsdk.CameraException as init_e:
self.logger.warning(f"CameraInit failed for {camera_name}: {init_e.message} (error_code: {init_e.error_code})") error_msg = f"CameraInit failed for {camera_name}: {init_e.message} (error_code: {init_e.error_code})"
# Special handling for error code 32774 (camera already in use)
if init_e.error_code == 32774:
error_msg += " - Camera may be in use by another process or resource conflict. "
error_msg += "This camera may still be functional if accessed through existing recorder/streamer."
self.logger.warning(error_msg)
# Mark as "available" but with warning, since it might be usable through existing connections
# The UI can show a warning but camera operations might still work
try:
device_info_dict = self._get_device_info_dict(device_info)
device_info_dict["init_error"] = "Camera appears in use (error 32774) but may be accessible"
device_info_dict["init_error_code"] = 32774
except Exception as dev_info_e:
self.logger.warning(f"Failed to get device info dict after CameraInit failure: {dev_info_e}")
device_info_dict = None
return "available", "Camera may be in use (error 32774) - check if recorder/streamer is active", device_info_dict
else:
self.logger.warning(error_msg)
# Get device info dict before returning - wrap in try/except in case device_info is corrupted # Get device info dict before returning - wrap in try/except in case device_info is corrupted
try: try:
device_info_dict = self._get_device_info_dict(device_info) device_info_dict = self._get_device_info_dict(device_info)

View File

@@ -26,6 +26,13 @@ from ..core.events import EventSystem, publish_recording_started, publish_record
from ..core.timezone_utils import now_atlanta, format_filename_timestamp from ..core.timezone_utils import now_atlanta, format_filename_timestamp
from .sdk_config import ensure_sdk_initialized from .sdk_config import ensure_sdk_initialized
from .utils import suppress_camera_errors from .utils import suppress_camera_errors
from .constants import (
CAMERA_GET_BUFFER_TIMEOUT,
CAMERA_INIT_TIMEOUT,
CAMERA_TEST_CAPTURE_TIMEOUT,
DEFAULT_VIDEO_FPS,
BRIEF_PAUSE_SLEEP,
)
class CameraRecorder: class CameraRecorder:

View File

@@ -46,7 +46,12 @@ class USDAVisionSystem:
self.storage_manager = StorageManager(self.config, self.state_manager, self.event_system) self.storage_manager = StorageManager(self.config, self.state_manager, self.event_system)
self.mqtt_client = MQTTClient(self.config, self.state_manager, self.event_system) self.mqtt_client = MQTTClient(self.config, self.state_manager, self.event_system)
self.camera_manager = CameraManager(self.config, self.state_manager, self.event_system) self.camera_manager = CameraManager(self.config, self.state_manager, self.event_system)
self.auto_recording_manager = StandaloneAutoRecorder(config=self.config) self.auto_recording_manager = StandaloneAutoRecorder(
config=self.config,
camera_manager=self.camera_manager,
state_manager=self.state_manager,
event_system=self.event_system
)
self.api_server = APIServer(self.config, self.state_manager, self.event_system, self.camera_manager, self.mqtt_client, self.storage_manager, self.auto_recording_manager) self.api_server = APIServer(self.config, self.state_manager, self.event_system, self.camera_manager, self.mqtt_client, self.storage_manager, self.auto_recording_manager)
# System state # System state

View File

@@ -172,14 +172,15 @@ class MQTTClient:
self.connected = True self.connected = True
self.state_manager.set_mqtt_connected(True) self.state_manager.set_mqtt_connected(True)
self.event_system.publish(EventType.MQTT_CONNECTED, "mqtt_client") self.event_system.publish(EventType.MQTT_CONNECTED, "mqtt_client")
self.logger.info("🔗 MQTT CONNECTED to broker successfully") self.logger.info(f"🔗 MQTT CONNECTED to broker successfully at {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}")
print(f"🔗 MQTT CONNECTED: {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}") print(f"🔗 MQTT CONNECTED: {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}")
# Subscribe to topics immediately after connection # Subscribe to topics immediately after connection
self._subscribe_to_topics() self._subscribe_to_topics()
self.logger.info(f"📋 MQTT subscribed to {len(self.mqtt_config.topics)} topics")
else: else:
self.connected = False self.connected = False
self.logger.error(f"❌ MQTT CONNECTION FAILED with return code {rc}") self.logger.error(f"❌ MQTT CONNECTION FAILED with return code {rc} to {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}")
print(f"❌ MQTT CONNECTION FAILED: {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port} (code: {rc})") print(f"❌ MQTT CONNECTION FAILED: {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port} (code: {rc})")
def _on_disconnect(self, client, userdata, rc) -> None: def _on_disconnect(self, client, userdata, rc) -> None:
@@ -201,7 +202,8 @@ class MQTTClient:
topic = msg.topic topic = msg.topic
payload = msg.payload.decode("utf-8").strip() payload = msg.payload.decode("utf-8").strip()
self.logger.debug(f"MQTT message received - Topic: {topic}, Payload: {payload}") # Log at INFO level so we can see messages in production
self.logger.info(f"📡 MQTT MESSAGE RECEIVED - Topic: {topic}, Payload: '{payload}'")
# Update MQTT activity and tracking # Update MQTT activity and tracking
self.state_manager.update_mqtt_activity() self.state_manager.update_mqtt_activity()
@@ -211,19 +213,20 @@ class MQTTClient:
# Get machine name from topic # Get machine name from topic
machine_name = self.topic_to_machine.get(topic) machine_name = self.topic_to_machine.get(topic)
if not machine_name: if not machine_name:
self.logger.warning(f"❓ MQTT UNKNOWN TOPIC: {topic}") self.logger.warning(f"❓ MQTT UNKNOWN TOPIC: {topic} (payload: '{payload}')")
print(f"❓ MQTT UNKNOWN TOPIC: {topic}") print(f"❓ MQTT UNKNOWN TOPIC: {topic} (payload: '{payload}')")
return return
# Show MQTT message on console # Show MQTT message on console with machine name
print(f"📡 MQTT MESSAGE: {machine_name}{payload}") print(f"📡 MQTT MESSAGE: {machine_name}{payload}")
self.logger.info(f"📡 Processing MQTT message for machine '{machine_name}': '{payload}'")
# Handle the message # Handle the message
self.message_handler.handle_message(machine_name, topic, payload) self.message_handler.handle_message(machine_name, topic, payload)
except Exception as e: except Exception as e:
self.error_count += 1 self.error_count += 1
self.logger.error(f"Error processing MQTT message: {e}") self.logger.error(f"Error processing MQTT message: {e}", exc_info=True)
def publish_message(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool: def publish_message(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
"""Publish a message to MQTT broker""" """Publish a message to MQTT broker"""

View File

@@ -31,10 +31,11 @@ class MQTTMessageHandler:
self.message_count += 1 self.message_count += 1
self.last_message_time = datetime.now() self.last_message_time = datetime.now()
self.logger.info(f"Processing MQTT message - Machine: {machine_name}, Topic: {topic}, Payload: {payload}") self.logger.info(f"📡 Processing MQTT message - Machine: {machine_name}, Topic: {topic}, Payload: '{payload}'")
# Normalize payload # Normalize payload
normalized_payload = self._normalize_payload(payload) normalized_payload = self._normalize_payload(payload)
self.logger.info(f"📡 Normalized payload '{payload}' -> '{normalized_payload}' for machine {machine_name}")
# Update machine state # Update machine state
state_changed = self.state_manager.update_machine_state(name=machine_name, state=normalized_payload, message=payload, topic=topic) state_changed = self.state_manager.update_machine_state(name=machine_name, state=normalized_payload, message=payload, topic=topic)
@@ -44,9 +45,12 @@ class MQTTMessageHandler:
# Publish state change event if state actually changed # Publish state change event if state actually changed
if state_changed: if state_changed:
self.logger.info(f"📡 MQTT: Machine {machine_name} state changed to: {normalized_payload}")
self.logger.info(f"📡 Publishing MACHINE_STATE_CHANGED event for {machine_name} -> {normalized_payload}")
publish_machine_state_changed(machine_name=machine_name, state=normalized_payload, source="mqtt_handler") publish_machine_state_changed(machine_name=machine_name, state=normalized_payload, source="mqtt_handler")
self.logger.info(f"✅ Published MACHINE_STATE_CHANGED event for {machine_name} -> {normalized_payload}")
self.logger.info(f"Machine {machine_name} state changed to: {normalized_payload}") else:
self.logger.info(f"📡 Machine {machine_name} state unchanged (still {normalized_payload}) - no event published")
# Log the message for debugging # Log the message for debugging
self._log_message_details(machine_name, topic, payload, normalized_payload) self._log_message_details(machine_name, topic, payload, normalized_payload)

View File

@@ -30,13 +30,13 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from usda_vision_system.core.config import Config from usda_vision_system.core.config import Config
from usda_vision_system.camera.recorder import CameraRecorder from usda_vision_system.camera.recorder import CameraRecorder
from usda_vision_system.core.state_manager import StateManager from usda_vision_system.core.state_manager import StateManager
from usda_vision_system.core.events import EventSystem from usda_vision_system.core.events import EventSystem, EventType, Event
class StandaloneAutoRecorder: class StandaloneAutoRecorder:
"""Standalone auto-recording system that monitors MQTT and controls cameras directly""" """Standalone auto-recording system that monitors MQTT and controls cameras directly"""
def __init__(self, config_path: str = "config.json", config: Optional[Config] = None): def __init__(self, config_path: str = "config.json", config: Optional[Config] = None, camera_manager=None, state_manager=None, event_system=None):
# Load configuration # Load configuration
if config: if config:
self.config = config self.config = config
@@ -45,9 +45,9 @@ class StandaloneAutoRecorder:
# Setup logging (only if not already configured) # Setup logging (only if not already configured)
if not logging.getLogger().handlers: if not logging.getLogger().handlers:
# Use WARNING level by default to reduce INFO log noise # Use configured log level
log_level = getattr(self.config.system, 'log_level', 'WARNING') log_level = getattr(self.config.system, 'log_level', 'INFO')
log_level_num = getattr(logging, log_level.upper(), logging.WARNING) log_level_num = getattr(logging, log_level.upper(), logging.INFO)
logging.basicConfig( logging.basicConfig(
level=log_level_num, level=log_level_num,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@@ -59,16 +59,17 @@ class StandaloneAutoRecorder:
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Ensure this logger respects the configured log level # Ensure this logger respects the configured log level
if hasattr(self.config, 'system') and hasattr(self.config.system, 'log_level'): if hasattr(self.config, 'system') and hasattr(self.config.system, 'log_level'):
self.logger.setLevel(getattr(logging, self.config.system.log_level.upper(), logging.WARNING)) self.logger.setLevel(getattr(logging, self.config.system.log_level.upper(), logging.INFO))
# Initialize components # Use provided components or create new ones
self.state_manager = StateManager() self.state_manager = state_manager if state_manager else StateManager()
self.event_system = EventSystem() self.event_system = event_system if event_system else EventSystem()
self.camera_manager = camera_manager
# MQTT client # MQTT client (only if not using event system)
self.mqtt_client: Optional[mqtt.Client] = None self.mqtt_client: Optional[mqtt.Client] = None
# Camera recorders # Camera recorders (only if not using camera_manager)
self.camera_recorders: Dict[str, CameraRecorder] = {} self.camera_recorders: Dict[str, CameraRecorder] = {}
self.active_recordings: Dict[str, str] = {} # camera_name -> filename self.active_recordings: Dict[str, str] = {} # camera_name -> filename
@@ -82,8 +83,17 @@ class StandaloneAutoRecorder:
self.running = False self.running = False
self._stop_event = threading.Event() self._stop_event = threading.Event()
# Subscribe to machine state change events if using event system
if self.event_system and self.camera_manager:
self.event_system.subscribe(EventType.MACHINE_STATE_CHANGED, self._on_machine_state_changed)
self.logger.info("Subscribed to MACHINE_STATE_CHANGED events")
self.logger.info("Standalone Auto-Recorder initialized") self.logger.info("Standalone Auto-Recorder initialized")
self.logger.info(f"Machine-Camera mapping: {self.machine_camera_map}") self.logger.info(f"Machine-Camera mapping: {self.machine_camera_map}")
if self.camera_manager:
self.logger.info("Using provided camera_manager for recording")
else:
self.logger.info("Will create own camera recorders (standalone mode)")
def _build_machine_camera_map(self) -> Dict[str, str]: def _build_machine_camera_map(self) -> Dict[str, str]:
"""Build mapping from machine topics to camera names""" """Build mapping from machine topics to camera names"""
@@ -162,80 +172,137 @@ class StandaloneAutoRecorder:
except Exception as e: except Exception as e:
self.logger.error(f"Error processing MQTT message: {e}") self.logger.error(f"Error processing MQTT message: {e}")
def _on_machine_state_changed(self, event: Event):
"""Handle machine state change event from event system"""
try:
machine_name = event.data.get("machine_name")
state = event.data.get("state", "").lower()
source = event.source
self.logger.info(f"📡 AUTO-RECORDER: Received MACHINE_STATE_CHANGED event from {source}")
self.logger.info(f"📡 AUTO-RECORDER: Event data - machine_name: {machine_name}, state: {state}")
if not machine_name or not state:
self.logger.warning(f"❌ AUTO-RECORDER: Invalid event data - machine_name: {machine_name}, state: {state}")
return
self._handle_machine_state_change(machine_name, state)
except Exception as e:
self.logger.error(f"❌ AUTO-RECORDER: Error handling machine state change event: {e}", exc_info=True)
def _handle_machine_state_change(self, machine_name: str, state: str): def _handle_machine_state_change(self, machine_name: str, state: str):
"""Handle machine state change""" """Handle machine state change"""
try: try:
# Check if we have a camera for this machine # Check if we have a camera for this machine
camera_name = self.machine_camera_map.get(machine_name) camera_name = self.machine_camera_map.get(machine_name)
if not camera_name: if not camera_name:
self.logger.debug(f"No camera mapped to machine: {machine_name}")
return return
self.logger.info(f"Handling state change: {machine_name} ({camera_name}) -> {state}") self.logger.info(f"📡 MQTT: Machine {machine_name} ({camera_name}) -> {state}")
if state == "on": if state == "on":
self._start_recording(camera_name, machine_name) self._start_recording(camera_name, machine_name)
elif state == "off": elif state == "off":
self._stop_recording(camera_name, machine_name) self._stop_recording(camera_name, machine_name)
else:
self.logger.debug(f"Ignoring state '{state}' for machine {machine_name}")
except Exception as e: except Exception as e:
self.logger.error(f"Error handling machine state change: {e}") self.logger.error(f"Error handling machine state change: {e}", exc_info=True)
def _start_recording(self, camera_name: str, machine_name: str): def _start_recording(self, camera_name: str, machine_name: str):
"""Start recording for a camera""" """Start recording for a camera"""
try: try:
# Check if already recording # Check if already recording
if camera_name in self.active_recordings: camera_info = self.state_manager.get_camera_status(camera_name) if self.state_manager else None
self.logger.warning(f"Camera {camera_name} is already recording") if camera_info and camera_info.is_recording:
self.logger.info(f"Camera {camera_name} is already recording, skipping")
return return
# Get or create camera recorder # Use camera_manager if available, otherwise use standalone recorder
recorder = self._get_camera_recorder(camera_name) if self.camera_manager:
if not recorder: # Generate filename with timestamp and machine info
self.logger.error(f"Failed to get recorder for camera {camera_name}") from ..core.timezone_utils import format_filename_timestamp
return timestamp = format_filename_timestamp()
camera_config = self.config.get_camera_by_name(camera_name)
video_format = camera_config.video_format if camera_config else "mp4"
filename = f"{camera_name}_auto_{machine_name}_{timestamp}.{video_format}"
# Generate filename with timestamp and machine info # Use camera manager to start recording with camera's default settings
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") success = self.camera_manager.manual_start_recording(
camera_config = self.config.get_camera_by_name(camera_name) camera_name=camera_name,
video_format = camera_config.video_format if camera_config else "mp4" filename=filename,
filename = f"{camera_name}_auto_{machine_name}_{timestamp}.{video_format}" exposure_ms=camera_config.exposure_ms if camera_config else None,
gain=camera_config.gain if camera_config else None,
fps=camera_config.target_fps if camera_config else None
)
# Start recording if success:
success = recorder.start_recording(filename) self.logger.info(f"✅ Started auto-recording: {camera_name} -> {filename}")
if success: self.active_recordings[camera_name] = filename
self.active_recordings[camera_name] = filename else:
self.logger.info(f"✅ Started recording: {camera_name} -> {filename}") self.logger.error(f"❌ Failed to start auto-recording for camera {camera_name}")
else: else:
self.logger.error(f"❌ Failed to start recording for camera {camera_name}") # Standalone mode - use own recorder
recorder = self._get_camera_recorder(camera_name)
if not recorder:
self.logger.error(f"Failed to get recorder for camera {camera_name}")
return
# Generate filename with timestamp and machine info
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
camera_config = self.config.get_camera_by_name(camera_name)
video_format = camera_config.video_format if camera_config else "mp4"
filename = f"{camera_name}_auto_{machine_name}_{timestamp}.{video_format}"
# Start recording
success = recorder.start_recording(filename)
if success:
self.active_recordings[camera_name] = filename
self.logger.info(f"✅ Started recording: {camera_name} -> {filename}")
else:
self.logger.error(f"❌ Failed to start recording for camera {camera_name}")
except Exception as e: except Exception as e:
self.logger.error(f"Error starting recording for {camera_name}: {e}") self.logger.error(f"Error starting recording for {camera_name}: {e}", exc_info=True)
def _stop_recording(self, camera_name: str, machine_name: str): def _stop_recording(self, camera_name: str, machine_name: str):
"""Stop recording for a camera""" """Stop recording for a camera"""
try: try:
# Check if recording # Use camera_manager if available
if camera_name not in self.active_recordings: if self.camera_manager:
self.logger.warning(f"Camera {camera_name} is not recording") success = self.camera_manager.manual_stop_recording(camera_name)
return if success:
self.logger.info(f"✅ Stopped auto-recording: {camera_name}")
# Get recorder if camera_name in self.active_recordings:
recorder = self._get_camera_recorder(camera_name) filename = self.active_recordings.pop(camera_name)
if not recorder: self.logger.debug(f"Recording filename was: {filename}")
self.logger.error(f"Failed to get recorder for camera {camera_name}") else:
return self.logger.warning(f"Camera {camera_name} may not have been recording")
# Stop recording
filename = self.active_recordings.pop(camera_name)
success = recorder.stop_recording()
if success:
self.logger.info(f"✅ Stopped recording: {camera_name} -> {filename}")
else: else:
self.logger.error(f"❌ Failed to stop recording for camera {camera_name}") # Standalone mode - use own recorder
if camera_name not in self.active_recordings:
self.logger.warning(f"Camera {camera_name} is not recording")
return
recorder = self._get_camera_recorder(camera_name)
if not recorder:
self.logger.error(f"Failed to get recorder for camera {camera_name}")
return
# Stop recording
filename = self.active_recordings.pop(camera_name)
success = recorder.stop_recording()
if success:
self.logger.info(f"✅ Stopped recording: {camera_name} -> {filename}")
else:
self.logger.error(f"❌ Failed to stop recording for camera {camera_name}")
except Exception as e: except Exception as e:
self.logger.error(f"Error stopping recording for {camera_name}: {e}") self.logger.error(f"Error stopping recording for {camera_name}: {e}", exc_info=True)
def _get_camera_recorder(self, camera_name: str) -> Optional[CameraRecorder]: def _get_camera_recorder(self, camera_name: str) -> Optional[CameraRecorder]:
"""Get or create camera recorder""" """Get or create camera recorder"""
@@ -356,19 +423,27 @@ class StandaloneAutoRecorder:
try: try:
self.logger.info("Starting Standalone Auto-Recorder...") self.logger.info("Starting Standalone Auto-Recorder...")
# Setup MQTT # If using event system and camera_manager, we don't need our own MQTT client
if self.event_system and self.camera_manager:
self.logger.info("Using event system - no need for separate MQTT client")
self.running = True
self.logger.info("✅ Standalone Auto-Recorder started successfully (event-based mode)")
return True
# Otherwise, setup MQTT client for standalone mode
if not self._setup_mqtt(): if not self._setup_mqtt():
self.logger.error("Failed to setup MQTT client")
return False return False
# Wait for MQTT connection # Wait for MQTT connection
time.sleep(2) time.sleep(2)
self.running = True self.running = True
self.logger.info("✅ Standalone Auto-Recorder started successfully") self.logger.info("✅ Standalone Auto-Recorder started successfully (standalone MQTT mode)")
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Failed to start auto-recorder: {e}") self.logger.error(f"Failed to start auto-recorder: {e}", exc_info=True)
return False return False
def stop(self) -> bool: def stop(self) -> bool:

View File

@@ -995,8 +995,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
return ( return (
<div className="p-6"> <div className="p-6 flex flex-col" style={{ height: 'calc(100vh - 48px)' }}>
<div className="mb-6"> <div className="mb-6 flex-shrink-0">
<button <button
onClick={onBack} onClick={onBack}
className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mb-4" className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mb-4"
@@ -1014,7 +1014,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
</p> </p>
</div> </div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 flex flex-col flex-1 min-h-0">
{error && ( {error && (
<div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div> <div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div>
)} )}
@@ -1029,7 +1029,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">Fetching conductors, phases, and experiments.</p> <p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">Fetching conductors, phases, and experiments.</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-shrink-0">
{/* Left: Conductors with future availability */} {/* Left: Conductors with future availability */}
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@@ -1250,7 +1250,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
</div> </div>
)} )}
{/* Week Calendar for selected conductors' availability */} {/* Week Calendar for selected conductors' availability */}
<div className="mt-6"> <div className="mt-6 flex flex-col flex-1 min-h-0">
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -1293,7 +1293,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
</button> </button>
</div> </div>
</div> </div>
<div ref={calendarRef} className="h-[80vh] border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative"> <div ref={calendarRef} className="flex-1 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative">
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<DnDCalendar <DnDCalendar
localizer={localizer} localizer={localizer}

View File

@@ -0,0 +1,129 @@
# Dark Mode Regex Patterns for Find & Replace
Use these regex patterns in VS Code (Ctrl+Shift+F) to find classes that need dark mode variants.
## Enable Regex Mode
Make sure the `.*` button is enabled in the Find dialog (regex mode).
## Patterns to Find Classes Without Dark Variants
### 1. Find `bg-white` without `dark:bg` in the same className
```regex
className="[^"]*\bbg-white\b(?!.*dark:bg)[^"]*"
```
**Simpler version** (finds bg-white in any context, you manually check):
```regex
\bbg-white\b
```
### 2. Find `text-gray-900` without `dark:text` in the same className
```regex
className="[^"]*\btext-gray-900\b(?!.*dark:text)[^"]*"
```
**Simpler version**:
```regex
\btext-gray-900\b
```
### 3. Find `text-gray-600` or `text-gray-700` without dark variants
```regex
className="[^"]*\btext-gray-[67]00\b(?!.*dark:text)[^"]*"
```
**Simpler version**:
```regex
\btext-gray-[67]00\b
```
### 4. Find `text-gray-500` without dark variant
```regex
\btext-gray-500\b
```
### 5. Find `border-gray-200` without dark variant
```regex
\bborder-gray-200\b
```
### 6. Find any `bg-gray-*` without dark variant (like bg-gray-50)
```regex
\bbg-gray-\d+\b
```
## Recommended Manual Process
Since the negative lookahead patterns are complex, here's a simpler workflow:
1. **Find all instances of a pattern:**
```
\bbg-white\b
```
2. **For each match, check if the same line/className contains `dark:bg-`**
- If NO → needs dark mode variant
- If YES → skip (already has dark mode)
3. **Replace patterns:**
### Replacement Patterns
**Backgrounds:**
- Find: `\bbg-white\b`
- Replace: `bg-white dark:bg-gray-800`
- Then manually add border if missing: `border border-gray-200 dark:border-gray-700`
**Text colors:**
- Find: `\btext-gray-900\b`
- Replace: `text-gray-900 dark:text-white`
- Find: `\btext-gray-800\b`
- Replace: `text-gray-800 dark:text-white/90`
- Find: `\btext-gray-700\b`
- Replace: `text-gray-700 dark:text-gray-300`
- Find: `\btext-gray-600\b`
- Replace: `text-gray-600 dark:text-gray-400`
- Find: `\btext-gray-500\b`
- Replace: `text-gray-500 dark:text-gray-400`
**Borders:**
- Find: `\bborder-gray-200\b`
- Replace: `border-gray-200 dark:border-gray-700`
- Find: `\bborder-gray-300\b`
- Replace: `border-gray-300 dark:border-gray-600`
**Table backgrounds:**
- Find: `\bbg-gray-50\b`
- Replace: `bg-gray-50 dark:bg-gray-900`
**Badges:**
- Find: `\bbg-blue-100\b`
- Replace: `bg-blue-100 dark:bg-blue-900/30`
- Find: `\btext-blue-800\b`
- Replace: `text-blue-800 dark:text-blue-300`
(Similar pattern for green, red, yellow, purple badges)
## Quick Find Patterns (Check Manually)
1. **All bg-white**: `\bbg-white\b`
2. **All text-gray-***: `\btext-gray-[0-9]+\b`
3. **All border-gray-***: `\bborder-gray-[0-9]+\b`
4. **All bg-gray-***: `\bbg-gray-[0-9]+\b`
## Example: Finding bg-white in className strings
If you want to be more precise and find `bg-white` that appears in a className attribute but doesn't have `dark:bg` nearby:
```regex
className="[^"]*\bbg-white\b[^"]*"(?!.*dark:bg)
```
But this won't work well if dark:bg is on a different line. So the simpler approach of finding all instances and manually checking is recommended.

View File

@@ -8,6 +8,7 @@
"name": "vision-system-remote", "name": "vision-system-remote",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@headlessui/react": "^2.1.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
}, },
@@ -748,6 +749,79 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@headlessui/react": {
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz",
"integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.20.2",
"@react-aria/interactions": "^3.25.0",
"@tanstack/react-virtual": "^3.13.9",
"use-sync-external-store": "^1.5.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -813,6 +887,103 @@
"pnpm": ">=7.0.1" "pnpm": ">=7.0.1"
} }
}, },
"node_modules/@react-aria/focus": {
"version": "3.21.2",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz",
"integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/interactions": "^3.25.6",
"@react-aria/utils": "^3.31.0",
"@react-types/shared": "^3.32.1",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.25.6",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz",
"integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-aria/utils": "^3.31.0",
"@react-stately/flags": "^3.1.2",
"@react-types/shared": "^3.32.1",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.10",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/utils": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz",
"integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-stately/flags": "^3.1.2",
"@react-stately/utils": "^3.10.8",
"@react-types/shared": "^3.32.1",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-stately/flags": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@react-stately/utils": {
"version": "3.10.8",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz",
"integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/shared": {
"version": "3.32.1",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz",
"integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1128,6 +1299,15 @@
"win32" "win32"
] ]
}, },
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.1.16", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
@@ -1410,6 +1590,33 @@
"vite": "^5.2.0 || ^6 || ^7" "vite": "^5.2.0 || ^6 || ^7"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1896,6 +2103,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3686,6 +3902,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tabbable": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz",
"integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==",
"license": "MIT"
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.16", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
@@ -3724,6 +3946,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-fest": { "node_modules/type-fest": {
"version": "2.19.0", "version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
@@ -3822,6 +4050,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@@ -12,6 +12,7 @@
"dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3002 --cors -c-1" "dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3002 --cors -c-1"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.1.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
}, },

View File

@@ -197,8 +197,9 @@ export const CameraCard: React.FC<CameraCardProps> = ({
onClick={() => onStopStreaming(cameraName)} onClick={() => onStopStreaming(cameraName)}
className="flex items-center justify-center px-3 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors" className="flex items-center justify-center px-3 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors"
title="Stop streaming" title="Stop streaming"
style={{ backgroundColor: '#ea580c', color: '#ffffff' }}
> >
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> <svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" style={{ color: '#ffffff' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -211,9 +212,10 @@ export const CameraCard: React.FC<CameraCardProps> = ({
<button <button
onClick={() => onRestart(cameraName)} onClick={() => onRestart(cameraName)}
className="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors" className="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors"
style={{ backgroundColor: '#ea580c', color: '#ffffff' }}
> >
<span className="flex items-center justify-center"> <span className="flex items-center justify-center" style={{ color: '#ffffff' }}>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: '#ffffff' }}>
<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" /> <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> </svg>
Restart Camera Restart Camera

View File

@@ -85,6 +85,21 @@ export interface MqttStatus {
uptime_seconds: number uptime_seconds: number
} }
export interface MqttEvent {
machine_name: string
topic: string
payload: string
normalized_state: string
timestamp: string
message_number: number
}
export interface MqttEventsResponse {
events: MqttEvent[]
total_events: number
last_updated: string | null
}
export interface StartRecordingResponse { export interface StartRecordingResponse {
success: boolean success: boolean
message: string message: string
@@ -220,6 +235,10 @@ class VisionApiClient {
return this.request('/mqtt/status') return this.request('/mqtt/status')
} }
async getMqttEvents(limit: number = 50): Promise<MqttEventsResponse> {
return this.request(`/mqtt/events?limit=${limit}`)
}
async startRecording(cameraName: string, filename?: string): Promise<StartRecordingResponse> { async startRecording(cameraName: string, filename?: string): Promise<StartRecordingResponse> {
return this.request(`/cameras/${cameraName}/start-recording`, { return this.request(`/cameras/${cameraName}/start-recording`, {
method: 'POST', method: 'POST',

View File

@@ -1,25 +1,57 @@
import React from 'react' import React, { useState, useEffect } from 'react'
import { StatusWidget } from './StatusWidget' import { StatusWidget } from './StatusWidget'
import type { SystemStatus } from '../services/api' import { visionApi, type SystemStatus, type MqttEvent } from '../services/api'
interface MqttStatusWidgetProps { interface MqttStatusWidgetProps {
systemStatus: SystemStatus | null systemStatus: SystemStatus | null
} }
export const MqttStatusWidget: React.FC<MqttStatusWidgetProps> = ({ systemStatus }) => { export const MqttStatusWidget: React.FC<MqttStatusWidgetProps> = ({ systemStatus }) => {
const [lastEvent, setLastEvent] = useState<MqttEvent | null>(null)
const isConnected = systemStatus?.mqtt_connected ?? false const isConnected = systemStatus?.mqtt_connected ?? false
const lastMessage = systemStatus?.last_mqtt_message const lastMessage = systemStatus?.last_mqtt_message
// Fetch the last MQTT event
useEffect(() => {
const fetchLastEvent = async () => {
try {
const eventsData = await visionApi.getMqttEvents(1).catch(() => null)
if (eventsData && eventsData.events.length > 0) {
setLastEvent(eventsData.events[0])
}
} catch (error) {
// Silently fail - don't clutter console
}
}
fetchLastEvent()
// Refresh every 5 seconds
const interval = setInterval(fetchLastEvent, 5000)
return () => clearInterval(interval)
}, [])
const formatLastEvent = () => {
if (!lastEvent) return null
const time = new Date(lastEvent.timestamp).toLocaleTimeString()
return `${lastEvent.machine_name}: ${lastEvent.normalized_state.toUpperCase()} (${time})`
}
const subtitle = lastEvent
? formatLastEvent()
: lastMessage
? `Last: ${new Date(lastMessage).toLocaleTimeString()}`
: 'No messages'
return ( return (
<StatusWidget <StatusWidget
title="MQTT Status" title="MQTT Status"
status={isConnected} status={isConnected}
statusText={isConnected ? 'Connected' : 'Disconnected'} statusText={isConnected ? 'Connected' : 'Disconnected'}
subtitle={lastMessage ? `Last: ${new Date(lastMessage).toLocaleTimeString()}` : 'No messages'} subtitle={subtitle || undefined}
icon={ icon={
<div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} /> <div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
} }
/> />
) )
} }