From b3a94d2d4fd5c4a8059850f7170ea2d98a087892 Mon Sep 17 00:00:00 2001 From: salirezav Date: Mon, 1 Dec 2025 15:30:10 -0500 Subject: [PATCH] Enhance Docker Compose configuration and improve camera manager error handling - Added container names for better identification of services in docker-compose.yml. - Refactored CameraManager to include error handling during initialization of camera recorders and streamers, ensuring the system remains operational even if some components fail. - Updated frontend components to support new MQTT Debug Panel functionality, enhancing monitoring capabilities. --- .../usda_vision_system/camera/manager.py | 31 +- docker-compose.yml | 7 + management-dashboard-web-app/src/App.tsx | 12 +- .../src/components/CameraPage.tsx | 530 ++++++++++++++++++ .../src/components/CameraRoute.tsx | 4 +- .../src/components/MqttDebugPanel.tsx | 120 ++++ .../src/components/VisionSystem.tsx | 25 +- .../src/lib/visionApi.ts | 7 + .../supabase/.temp/cli-latest | 2 +- .../migrations/00002_users_and_roles.sql | 118 ++++ .../supabase/seed_01_users.sql | 48 ++ vision-system-remote/src/App.tsx | 61 +- .../src/widgets/MqttStatusWidget.tsx | 34 +- vision-system-remote/vite.config.ts | 8 + 14 files changed, 940 insertions(+), 67 deletions(-) create mode 100644 management-dashboard-web-app/src/components/CameraPage.tsx create mode 100644 management-dashboard-web-app/src/components/MqttDebugPanel.tsx diff --git a/camera-management-api/usda_vision_system/camera/manager.py b/camera-management-api/usda_vision_system/camera/manager.py index 189327c..85972fc 100644 --- a/camera-management-api/usda_vision_system/camera/manager.py +++ b/camera-management-api/usda_vision_system/camera/manager.py @@ -75,18 +75,31 @@ class CameraManager: self.logger.info("Starting camera manager...") self.running = True - # Start camera monitor - if self.camera_monitor: - self.camera_monitor.start() + try: + # Start camera monitor + if self.camera_monitor: + self.camera_monitor.start() - # Initialize camera recorders - self._initialize_recorders() + # Initialize camera recorders + try: + self._initialize_recorders() + except Exception as e: + self.logger.error(f"Error initializing camera recorders: {e}", exc_info=True) + # Continue anyway - some cameras might still work - # Initialize camera streamers - self._initialize_streamers() + # Initialize camera streamers + try: + self._initialize_streamers() + except Exception as e: + self.logger.error(f"Error initializing camera streamers: {e}", exc_info=True) + # Continue anyway - streaming is optional - self.logger.info("Camera manager started successfully") - return True + self.logger.info("Camera manager started successfully") + return True + except Exception as e: + self.logger.error(f"Critical error starting camera manager: {e}", exc_info=True) + self.running = False + return False def stop(self) -> None: """Stop the camera manager""" diff --git a/docker-compose.yml b/docker-compose.yml index 6eb5f0b..a303ec1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: api: + container_name: usda-vision-api build: context: ./camera-management-api dockerfile: Dockerfile @@ -45,6 +46,7 @@ services: network_mode: host web: + container_name: usda-vision-web image: node:20-alpine working_dir: /app env_file: @@ -66,6 +68,7 @@ services: - "8080:8080" video-remote: + container_name: usda-vision-video-remote image: node:20-alpine working_dir: /app environment: @@ -86,6 +89,7 @@ services: - "3001:3001" vision-system-remote: + container_name: usda-vision-vision-system-remote image: node:20-alpine working_dir: /app environment: @@ -105,6 +109,7 @@ services: - "3002:3002" scheduling-remote: + container_name: usda-vision-scheduling-remote image: node:20-alpine working_dir: /app env_file: @@ -125,6 +130,7 @@ services: - "3003:3003" media-api: + container_name: usda-vision-media-api build: context: ./media-api dockerfile: Dockerfile @@ -150,6 +156,7 @@ services: # mem_reservation: 512m mediamtx: + container_name: usda-vision-mediamtx image: bluenviron/mediamtx:latest volumes: - ./mediamtx.yml:/mediamtx.yml:ro diff --git a/management-dashboard-web-app/src/App.tsx b/management-dashboard-web-app/src/App.tsx index 08647f5..f7f3786 100755 --- a/management-dashboard-web-app/src/App.tsx +++ b/management-dashboard-web-app/src/App.tsx @@ -87,15 +87,15 @@ function App() { } } - // Check if current route is a camera live route - const isCameraLiveRoute = (route: string) => { - const cameraRoutePattern = /^\/camera(\d+)\/live$/ + // Check if current route is a camera route (no authentication required) + const isCameraRoute = (route: string) => { + const cameraRoutePattern = /^\/camera(\d+)$/ return cameraRoutePattern.test(route) } // Extract camera number from route const getCameraNumber = (route: string) => { - const match = route.match(/^\/camera(\d+)\/live$/) + const match = route.match(/^\/camera(\d+)$/) return match ? `camera${match[1]}` : null } @@ -136,8 +136,8 @@ function App() { ) } - // Handle camera live routes (no authentication required) - if (isCameraLiveRoute(currentRoute)) { + // Handle camera routes (no authentication required) + if (isCameraRoute(currentRoute)) { const cameraNumber = getCameraNumber(currentRoute) if (cameraNumber) { return diff --git a/management-dashboard-web-app/src/components/CameraPage.tsx b/management-dashboard-web-app/src/components/CameraPage.tsx new file mode 100644 index 0000000..14077df --- /dev/null +++ b/management-dashboard-web-app/src/components/CameraPage.tsx @@ -0,0 +1,530 @@ +import { useState, useEffect, useRef } from 'react' +import { visionApi, type CameraStatus, type CameraConfig, type MqttEvent } from '../lib/visionApi' + +interface CameraPageProps { + cameraName: string +} + +export function CameraPage({ cameraName }: CameraPageProps) { + const [cameraStatus, setCameraStatus] = useState(null) + const [cameraConfig, setCameraConfig] = useState(null) + const [isStreaming, setIsStreaming] = useState(false) + const [streamStatus, setStreamStatus] = useState<'idle' | 'starting' | 'streaming' | 'stopping' | 'error'>('idle') + const [isRecording, setIsRecording] = useState(false) + const [mqttEvents, setMqttEvents] = useState([]) + const [autoRecordingError, setAutoRecordingError] = useState(null) + const [loading, setLoading] = useState(true) + const imgRef = useRef(null) + const statusIntervalRef = useRef(null) + const mqttEventsIntervalRef = useRef(null) + + // Load initial data and auto-start stream + useEffect(() => { + loadCameraData() + // Auto-start stream when component loads + startStreaming() + return () => { + if (statusIntervalRef.current) { + clearInterval(statusIntervalRef.current) + } + if (mqttEventsIntervalRef.current) { + clearInterval(mqttEventsIntervalRef.current) + } + stopStreaming() + } + }, [cameraName]) + + // Poll camera status + useEffect(() => { + statusIntervalRef.current = window.setInterval(() => { + loadCameraStatus() + }, 2000) // Poll every 2 seconds + + return () => { + if (statusIntervalRef.current) { + clearInterval(statusIntervalRef.current) + } + } + }, [cameraName]) + + // Poll MQTT events + useEffect(() => { + loadMqttEvents() + mqttEventsIntervalRef.current = window.setInterval(() => { + loadMqttEvents() + }, 3000) // Poll every 3 seconds + + return () => { + if (mqttEventsIntervalRef.current) { + clearInterval(mqttEventsIntervalRef.current) + } + } + }, [cameraName, cameraConfig]) + + // Update image src when streaming state changes + useEffect(() => { + if (isStreaming && imgRef.current && streamStatus === 'streaming') { + // Set stream URL with timestamp to prevent caching + const streamUrl = `${visionApi.getStreamUrl(cameraName)}?t=${Date.now()}` + if (imgRef.current.src !== streamUrl) { + imgRef.current.src = streamUrl + } + } else if (!isStreaming && imgRef.current) { + imgRef.current.src = '' + } + }, [isStreaming, streamStatus, cameraName]) + + // Monitor auto-recording failures + useEffect(() => { + if (cameraStatus && cameraConfig) { + // Check if auto-recording is enabled + if (cameraStatus.auto_recording_enabled) { + // Check if there was a recent MQTT event that should have triggered recording + const recentMqttEvent = mqttEvents.find(event => { + // Check if this event is for the camera's machine topic and state is "on" + return ( + event.machine_name === cameraConfig.machine_topic && + event.normalized_state === 'on' && + new Date(event.timestamp).getTime() > Date.now() - 60000 // Within last 60 seconds + ) + }) + + // If there was a recent trigger event, check if recording actually started + if (recentMqttEvent) { + // If recording didn't start and there's an error, show the error + if (!cameraStatus.is_recording && cameraStatus.auto_recording_last_error) { + setAutoRecordingError( + `Auto-recording failed to start! MQTT trigger received (${cameraConfig.machine_topic} → ON) but recording did not start. Error: ${cameraStatus.auto_recording_last_error}` + ) + } else if (!cameraStatus.is_recording && cameraStatus.auto_recording_failure_count > 0) { + // Even without last_error, if there are failures and no recording, show warning + setAutoRecordingError( + `Auto-recording failed to start! MQTT trigger received (${cameraConfig.machine_topic} → ON) but recording did not start after ${cameraStatus.auto_recording_failure_count} attempt(s).` + ) + } else { + // Recording started successfully or no error yet + setAutoRecordingError(null) + } + } else { + // No recent trigger event, clear error + setAutoRecordingError(null) + } + } else { + // Auto-recording not enabled, clear error + setAutoRecordingError(null) + } + } else { + setAutoRecordingError(null) + } + }, [cameraStatus, mqttEvents, cameraConfig]) + + const loadCameraData = async () => { + try { + setLoading(true) + await Promise.all([loadCameraStatus(), loadCameraConfig()]) + } catch (error) { + console.error('Error loading camera data:', error) + } finally { + setLoading(false) + } + } + + const loadCameraStatus = async () => { + try { + const status = await visionApi.getCameraStatus(cameraName) + setCameraStatus(status) + setIsRecording(status.is_recording) + + // Update stream status based on camera status + if (status.status === 'streaming' || status.status === 'available') { + if (!isStreaming) { + setIsStreaming(true) + setStreamStatus('streaming') + } + } + } catch (error) { + console.error('Error loading camera status:', error) + } + } + + const loadCameraConfig = async () => { + try { + const config = await visionApi.getCameraConfig(cameraName) + setCameraConfig(config) + } catch (error) { + console.error('Error loading camera config:', error) + } + } + + const loadMqttEvents = async () => { + try { + const response = await visionApi.getMqttEvents(20) // Get last 20 events + if (cameraConfig) { + // Filter events relevant to this camera's machine topic + const relevantEvents = response.events.filter( + event => event.machine_name === cameraConfig.machine_topic + ) + setMqttEvents(relevantEvents) + } else { + // If config not loaded yet, show all events + setMqttEvents(response.events) + } + } catch (error) { + console.error('Error loading MQTT events:', error) + } + } + + const startStreaming = async () => { + try { + setStreamStatus('starting') + const result = await visionApi.startStream(cameraName) + if (result.success) { + setIsStreaming(true) + setStreamStatus('streaming') + // The useEffect will handle setting the image src + } else { + setStreamStatus('error') + setIsStreaming(false) + throw new Error(result.message || 'Failed to start stream') + } + } catch (error) { + setStreamStatus('error') + setIsStreaming(false) + console.error('Error starting stream:', error) + } + } + + const stopStreaming = async () => { + try { + setStreamStatus('stopping') + await visionApi.stopStream(cameraName) + setIsStreaming(false) + setStreamStatus('idle') + if (imgRef.current) { + imgRef.current.src = '' + } + } catch (error) { + console.error('Error stopping stream:', error) + setStreamStatus('error') + } + } + + const startRecording = async () => { + try { + const result = await visionApi.startRecording(cameraName) + if (result.success) { + setIsRecording(true) + await loadCameraStatus() // Refresh status + } else { + throw new Error(result.message || 'Failed to start recording') + } + } catch (error) { + console.error('Error starting recording:', error) + alert(`Failed to start recording: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + const stopRecording = async () => { + try { + const result = await visionApi.stopRecording(cameraName) + if (result.success) { + setIsRecording(false) + await loadCameraStatus() // Refresh status + } else { + throw new Error(result.message || 'Failed to stop recording') + } + } catch (error) { + console.error('Error stopping recording:', error) + alert(`Failed to stop recording: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + const getHealthStatus = () => { + if (!cameraStatus) return { status: 'unknown', message: 'Loading...' } + + if (cameraStatus.device_info) { + return { status: 'healthy', message: 'Initialized & Found on Network' } + } else if (cameraStatus.status === 'disconnected' || cameraStatus.status === 'error') { + return { status: 'error', message: cameraStatus.last_error || 'Not Found on Network' } + } else { + return { status: 'warning', message: 'Status Unknown' } + } + } + + const getStreamStatusText = () => { + switch (streamStatus) { + case 'idle': + return 'Ready to Stream' + case 'starting': + return 'Starting...' + case 'streaming': + return 'Now Streaming' + case 'stopping': + return 'Stopping...' + case 'error': + return 'Error' + default: + return 'Unknown' + } + } + + const formatTimestamp = (timestamp: string) => { + try { + const date = new Date(timestamp) + return date.toLocaleTimeString() + } catch { + return timestamp + } + } + + if (loading) { + return ( +
+
+
+

Loading camera data...

+
+
+ ) + } + + const healthStatus = getHealthStatus() + + return ( +
+ {/* Left Side - Live Stream (3/5) */} +
+ {/* Auto-recording Error Banner */} + {autoRecordingError && ( +
+
+ + + + {autoRecordingError} +
+ +
+ )} + + {/* Stream Container */} +
+ {isStreaming && streamStatus === 'streaming' ? ( + {`Live { + console.error('Stream image failed to load, retrying...') + // Retry loading the stream + if (imgRef.current) { + setTimeout(() => { + if (imgRef.current && isStreaming) { + imgRef.current.src = `${visionApi.getStreamUrl(cameraName)}?t=${Date.now()}` + } + }, 1000) + } + }} + onLoad={() => { + // Stream loaded successfully + console.log('Stream loaded successfully') + }} + /> + ) : streamStatus === 'starting' ? ( +
+
+

Starting stream...

+
+ ) : ( +
+ + + +

Stream not active

+

Click "Start Stream" to begin

+
+ )} + + {/* Camera Label Overlay */} +
+
+ {cameraName} - Live View +
+
+ + {/* Stream Status Indicator */} + {isStreaming && ( +
+
+
+ LIVE +
+
+ )} +
+
+ + {/* Right Side - Controls & Info (2/5) */} +
+

{cameraName.toUpperCase()}

+ + {/* Health Status */} +
+

Health Status

+
+
+
+ {healthStatus.message} +
+ {cameraStatus?.device_info && ( +
+
Model: {cameraStatus.device_info.model || 'N/A'}
+
Serial: {cameraStatus.device_info.serial_number || 'N/A'}
+
+ )} +
+
+ + {/* Stream Controls */} +
+

Stream Control

+
+
+
Status:
+
+ {getStreamStatusText()} +
+
+
+ +
+
+
+ + {/* Recording Status */} +
+

Recording Status

+
+
+
Status:
+
+ {isRecording ? 'Recording Now' : 'Not Recording'} +
+
+ {isRecording && cameraStatus?.current_recording_file && ( +
+ File: {cameraStatus.current_recording_file.split('/').pop()} +
+ )} +
+ +
+
+
+ + {/* MQTT Message Log */} +
+

MQTT Message Log

+
+
+ {mqttEvents.length === 0 ? ( +
+ No MQTT messages received yet +
+ ) : ( + mqttEvents.map((event, index) => ( +
+
+ {event.machine_name} + {formatTimestamp(event.timestamp)} +
+
+ State: + + {event.normalized_state.toUpperCase()} + +
+
+ Topic: {event.topic} +
+
+ )) + )} +
+
+
+ + {/* Auto-Recording Info */} + {cameraStatus && cameraStatus.auto_recording_enabled && ( +
+

Auto-Recording

+
+
+
+ ✓ Enabled +
+ {cameraConfig && ( +
+ Machine Topic: {cameraConfig.machine_topic} +
+ )} + {cameraStatus.auto_recording_failure_count > 0 && ( +
+ Failures: {cameraStatus.auto_recording_failure_count} +
+ )} +
+
+
+ )} +
+
+ ) +} + diff --git a/management-dashboard-web-app/src/components/CameraRoute.tsx b/management-dashboard-web-app/src/components/CameraRoute.tsx index aa0d688..3a2c9b5 100755 --- a/management-dashboard-web-app/src/components/CameraRoute.tsx +++ b/management-dashboard-web-app/src/components/CameraRoute.tsx @@ -1,4 +1,4 @@ -import { LiveCameraView } from './LiveCameraView' +import { CameraPage } from './CameraPage' interface CameraRouteProps { cameraNumber: string @@ -17,7 +17,7 @@ export function CameraRoute({ cameraNumber }: CameraRouteProps) { ) } - return + return } diff --git a/management-dashboard-web-app/src/components/MqttDebugPanel.tsx b/management-dashboard-web-app/src/components/MqttDebugPanel.tsx new file mode 100644 index 0000000..e558774 --- /dev/null +++ b/management-dashboard-web-app/src/components/MqttDebugPanel.tsx @@ -0,0 +1,120 @@ +import React, { useState } from 'react' +import { visionApi } from '../lib/visionApi' + +interface MqttDebugPanelProps { + isOpen: boolean + onClose: () => void +} + +export const MqttDebugPanel: React.FC = ({ isOpen, onClose }) => { + const [loading, setLoading] = useState(null) + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + + const publishMessage = async (topic: string, payload: string) => { + const action = `${topic.split('/').pop()} → ${payload}` + setLoading(action) + setMessage(null) + + try { + const result = await visionApi.publishMqttMessage(topic, payload, 0, false) + if (result.success) { + setMessage({ type: 'success', text: `Published: ${action}` }) + setTimeout(() => setMessage(null), 3000) + } else { + setMessage({ type: 'error', text: `Failed: ${result.message}` }) + } + } catch (error: any) { + setMessage({ type: 'error', text: `Error: ${error.message || 'Failed to publish message'}` }) + } finally { + setLoading(null) + } + } + + if (!isOpen) return null + + return ( +
+
+ {/* Header */} +
+

MQTT Debug Panel

+ +
+ + {/* Content */} +
+ {/* Message Status */} + {message && ( +
+ {message.text} +
+ )} + + {/* Vibratory Conveyor Section */} +
+

Vibratory Conveyor

+

Topic: vision/vibratory_conveyor/state

+
+ + +
+
+ + {/* Blower Separator Section */} +
+

Blower Separator

+

Topic: vision/blower_separator/state

+
+ + +
+
+ + {/* Info */} +
+

+ Note: These buttons publish MQTT messages to test auto-recording. + Check the logs to see if cameras start/stop recording automatically. +

+
+
+
+
+ ) +} + diff --git a/management-dashboard-web-app/src/components/VisionSystem.tsx b/management-dashboard-web-app/src/components/VisionSystem.tsx index 393f5a0..c12eec4 100755 --- a/management-dashboard-web-app/src/components/VisionSystem.tsx +++ b/management-dashboard-web-app/src/components/VisionSystem.tsx @@ -16,9 +16,10 @@ import { import { useAuth } from '../hooks/useAuth' import { CameraConfigModal } from './CameraConfigModal' import { CameraPreviewModal } from './CameraPreviewModal' +import { MqttDebugPanel } from './MqttDebugPanel' // Memoized components to prevent unnecessary re-renders -const SystemOverview = memo(({ systemStatus }: { systemStatus: SystemStatus }) => ( +const SystemOverview = memo(({ systemStatus, onMqttDebugClick }: { systemStatus: SystemStatus, onMqttDebugClick?: () => void }) => (
@@ -38,7 +39,7 @@ const SystemOverview = memo(({ systemStatus }: { systemStatus: SystemStatus }) =
-
+
@@ -54,6 +55,17 @@ const SystemOverview = memo(({ systemStatus }: { systemStatus: SystemStatus }) =
+ {onMqttDebugClick && ( + + )}
@@ -499,6 +511,9 @@ export function VisionSystem() { const [previewModalOpen, setPreviewModalOpen] = useState(false) const [previewCamera, setPreviewCamera] = useState(null) + // MQTT debug panel state + const [debugPanelOpen, setDebugPanelOpen] = useState(false) + const intervalRef = useRef(null) const clearAutoRefresh = useCallback(() => { @@ -837,7 +852,7 @@ export function VisionSystem() {
{/* System Overview */} - {systemStatus && } + {systemStatus && setDebugPanelOpen(true)} />} @@ -932,6 +947,10 @@ export function VisionSystem() { setPreviewCamera(null) }} /> + setDebugPanelOpen(false)} + /> )} {/* Notification */} diff --git a/management-dashboard-web-app/src/lib/visionApi.ts b/management-dashboard-web-app/src/lib/visionApi.ts index 6e496c3..635afc1 100755 --- a/management-dashboard-web-app/src/lib/visionApi.ts +++ b/management-dashboard-web-app/src/lib/visionApi.ts @@ -329,6 +329,13 @@ class VisionApiClient { return this.request(`/mqtt/events?limit=${limit}`) } + async publishMqttMessage(topic: string, payload: string, qos: number = 0, retain: boolean = false): Promise<{ success: boolean; message: string; topic: string; payload: string }> { + return this.request('/mqtt/publish', { + method: 'POST', + body: JSON.stringify({ topic, payload, qos, retain }), + }) + } + // Camera endpoints async getCameras(): Promise> { return this.request('/cameras') diff --git a/management-dashboard-web-app/supabase/.temp/cli-latest b/management-dashboard-web-app/supabase/.temp/cli-latest index 11335d2..7a78572 100755 --- a/management-dashboard-web-app/supabase/.temp/cli-latest +++ b/management-dashboard-web-app/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.54.11 \ No newline at end of file +v2.62.10 \ No newline at end of file diff --git a/management-dashboard-web-app/supabase/migrations/00002_users_and_roles.sql b/management-dashboard-web-app/supabase/migrations/00002_users_and_roles.sql index 4b3a3c5..09d1f2f 100644 --- a/management-dashboard-web-app/supabase/migrations/00002_users_and_roles.sql +++ b/management-dashboard-web-app/supabase/migrations/00002_users_and_roles.sql @@ -114,6 +114,124 @@ CREATE POLICY "User roles are updatable by authenticated users" ON public.user_r CREATE POLICY "User roles are deletable by authenticated users" ON public.user_roles FOR DELETE USING (auth.role() = 'authenticated'); +-- ============================================= +-- 9. USER MANAGEMENT FUNCTIONS +-- ============================================= + +-- Function to create a new user with roles +CREATE OR REPLACE FUNCTION public.create_user_with_roles( + user_email TEXT, + role_names TEXT[], + temp_password TEXT +) +RETURNS JSON AS $$ +DECLARE + new_user_id UUID; + encrypted_pwd TEXT; + role_name TEXT; + role_id_val UUID; + assigned_by_id UUID; + result JSON; + user_roles_array TEXT[]; +BEGIN + -- Generate new user ID + new_user_id := uuid_generate_v4(); + + -- Encrypt the password + encrypted_pwd := crypt(temp_password, gen_salt('bf')); + + -- Get the current user ID for assigned_by, but only if they have a profile + -- Otherwise, use the new user ID (which we'll create next) + SELECT id INTO assigned_by_id + FROM public.user_profiles + WHERE id = auth.uid(); + + -- If no valid assigned_by user found, use the new user ID (self-assigned) + IF assigned_by_id IS NULL THEN + assigned_by_id := new_user_id; + END IF; + + -- Create user in auth.users + INSERT INTO auth.users ( + instance_id, + id, + aud, + role, + email, + encrypted_password, + email_confirmed_at, + created_at, + updated_at, + confirmation_token, + email_change, + email_change_token_new, + recovery_token + ) VALUES ( + '00000000-0000-0000-0000-000000000000', + new_user_id, + 'authenticated', + 'authenticated', + user_email, + encrypted_pwd, + NOW(), + NOW(), + NOW(), + '', + '', + '', + '' + ); + + -- Create user profile + INSERT INTO public.user_profiles (id, email, status) + VALUES (new_user_id, user_email, 'active'); + + -- Assign roles + user_roles_array := ARRAY[]::TEXT[]; + FOREACH role_name IN ARRAY role_names + LOOP + -- Get role ID + SELECT id INTO role_id_val + FROM public.roles + WHERE name = role_name; + + -- If role exists, assign it + IF role_id_val IS NOT NULL THEN + INSERT INTO public.user_roles (user_id, role_id, assigned_by) + VALUES (new_user_id, role_id_val, assigned_by_id) + ON CONFLICT (user_id, role_id) DO NOTHING; + + -- Add to roles array for return value + user_roles_array := array_append(user_roles_array, role_name); + END IF; + END LOOP; + + -- Return the result as JSON + result := json_build_object( + 'user_id', new_user_id::TEXT, + 'email', user_email, + 'temp_password', temp_password, + 'roles', user_roles_array, + 'status', 'active' + ); + + RETURN result; + +EXCEPTION + WHEN unique_violation THEN + RAISE EXCEPTION 'User with email % already exists', user_email; + WHEN OTHERS THEN + RAISE EXCEPTION 'Error creating user: %', SQLERRM; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Grant execute permission on the function +GRANT EXECUTE ON FUNCTION public.create_user_with_roles(TEXT, TEXT[], TEXT) TO authenticated; + +-- Comment for documentation +COMMENT ON FUNCTION public.create_user_with_roles(TEXT, TEXT[], TEXT) IS +'Creates a new user in auth.users, creates a profile in user_profiles, and assigns the specified roles. Returns user information including user_id, email, temp_password, roles, and status.'; + diff --git a/management-dashboard-web-app/supabase/seed_01_users.sql b/management-dashboard-web-app/supabase/seed_01_users.sql index 1e036bf..2ae1120 100755 --- a/management-dashboard-web-app/supabase/seed_01_users.sql +++ b/management-dashboard-web-app/supabase/seed_01_users.sql @@ -507,6 +507,54 @@ AND r.name IN ('conductor', 'data recorder') ; +-- Create engr-ugaif user (Conductor, Analyst & Data Recorder) +INSERT INTO auth.users ( + instance_id, + id, + aud, + role, + email, + encrypted_password, + email_confirmed_at, + created_at, + updated_at, + confirmation_token, + email_change, + email_change_token_new, + recovery_token +) VALUES ( + '00000000-0000-0000-0000-000000000000', + uuid_generate_v4(), + 'authenticated', + 'authenticated', + 'engr-ugaif@uga.edu', + crypt('1048lab&2021', gen_salt('bf')), + NOW(), + NOW(), + NOW(), + '', + '', + '', + '' +); + +INSERT INTO public.user_profiles (id, email, status) +SELECT id, email, 'active' +FROM auth.users +WHERE email = 'engr-ugaif@uga.edu' +; + +INSERT INTO public.user_roles (user_id, role_id, assigned_by) +SELECT + up.id, + r.id, + (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com') +FROM public.user_profiles up +CROSS JOIN public.roles r +WHERE up.email = 'engr-ugaif@uga.edu' +AND r.name IN ('conductor', 'analyst', 'data recorder') +; + -- ============================================= -- 4. CREATE MACHINE TYPES -- ============================================= diff --git a/vision-system-remote/src/App.tsx b/vision-system-remote/src/App.tsx index 77adad1..477f0c6 100644 --- a/vision-system-remote/src/App.tsx +++ b/vision-system-remote/src/App.tsx @@ -13,13 +13,13 @@ import { MqttDebugPanel } from './components/MqttDebugPanel' // Get WebSocket URL from environment or construct it const getWebSocketUrl = () => { const apiUrl = import.meta.env.VITE_VISION_API_URL || '/api' - + // If it's a relative path, use relative WebSocket URL if (apiUrl.startsWith('/')) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' return `${protocol}//${window.location.host}${apiUrl.replace(/\/api$/, '')}/ws` } - + // Convert http(s):// to ws(s):// const wsUrl = apiUrl.replace(/^http/, 'ws') return `${wsUrl.replace(/\/api$/, '')}/ws` @@ -33,7 +33,7 @@ export default function App() { const [error, setError] = useState(null) const [lastUpdate, setLastUpdate] = useState(null) const [notification, setNotification] = useState<{ type: 'success' | 'error', message: string } | null>(null) - + // Modal states const [previewModalOpen, setPreviewModalOpen] = useState(false) const [previewCamera, setPreviewCamera] = useState(null) @@ -100,13 +100,13 @@ export default function App() { is_recording: true, }, })) - + // Refresh recordings to get accurate count visionApi.getRecordings().then(setRecordings).catch(console.error) - + // Refresh system status to update counts visionApi.getSystemStatus().then(setSystemStatus).catch(console.error) - + setLastUpdate(new Date()) }) ) @@ -122,7 +122,7 @@ export default function App() { is_recording: false, }, })) - + // Refresh recordings and system status Promise.all([ visionApi.getRecordings(), @@ -131,7 +131,7 @@ export default function App() { setRecordings(recordingsData) setSystemStatus(statusData) }).catch(console.error) - + setLastUpdate(new Date()) }) ) @@ -171,7 +171,7 @@ export default function App() { if (result.success) { setNotification({ type: 'success', message: `Recording started: ${result.filename}` }) - + // Immediately update state optimistically (UI updates instantly) setCameras((prev) => ({ ...prev, @@ -181,7 +181,7 @@ export default function App() { current_recording_file: result.filename, }, })) - + // Refresh camera status from API as backup (in case WebSocket is delayed) setTimeout(() => { visionApi.getCameras().then(setCameras).catch(console.error) @@ -199,7 +199,7 @@ export default function App() { const result = await visionApi.stopRecording(cameraName) if (result.success) { setNotification({ type: 'success', message: 'Recording stopped' }) - + // Immediately update state optimistically (UI updates instantly) setCameras((prev) => ({ ...prev, @@ -209,7 +209,7 @@ export default function App() { current_recording_file: null, }, })) - + // Refresh camera status from API as backup (in case WebSocket is delayed) setTimeout(() => { visionApi.getCameras().then(setCameras).catch(console.error) @@ -241,7 +241,7 @@ export default function App() { status: 'streaming', }, })) - + // Open camera stream in new window/tab const streamUrl = visionApi.getStreamUrl(cameraName) window.open(streamUrl, '_blank') @@ -262,7 +262,7 @@ export default function App() { try { setNotification({ type: 'success', message: `Restarting camera ${cameraName}...` }) const result = await visionApi.reinitializeCamera(cameraName) - + if (result.success) { setNotification({ type: 'success', message: `Camera ${cameraName} restarted successfully` }) // Refresh camera status @@ -283,7 +283,7 @@ export default function App() { const result = await visionApi.stopStream(cameraName) if (result.success) { setNotification({ type: 'success', message: 'Streaming stopped' }) - + // Immediately update camera status (UI updates instantly) setCameras((prev) => ({ ...prev, @@ -292,7 +292,7 @@ export default function App() { status: 'available', }, })) - + // Refresh camera status from API as backup setTimeout(() => { visionApi.getCameras().then(setCameras).catch(console.error) @@ -390,7 +390,10 @@ export default function App() { {/* Status Widgets */}
- + setDebugPanelOpen(true)} + />
@@ -426,11 +429,10 @@ export default function App() { {/* Notification */} {notification && (
@@ -450,11 +452,10 @@ export default function App() {
) } diff --git a/vision-system-remote/src/widgets/MqttStatusWidget.tsx b/vision-system-remote/src/widgets/MqttStatusWidget.tsx index 0759499..151824e 100644 --- a/vision-system-remote/src/widgets/MqttStatusWidget.tsx +++ b/vision-system-remote/src/widgets/MqttStatusWidget.tsx @@ -4,9 +4,10 @@ import { visionApi, type SystemStatus, type MqttEvent } from '../services/api' interface MqttStatusWidgetProps { systemStatus: SystemStatus | null + onDebugClick?: () => void } -export const MqttStatusWidget: React.FC = ({ systemStatus }) => { +export const MqttStatusWidget: React.FC = ({ systemStatus, onDebugClick }) => { const [lastEvent, setLastEvent] = useState(null) const isConnected = systemStatus?.mqtt_connected ?? false const lastMessage = systemStatus?.last_mqtt_message @@ -44,14 +45,27 @@ export const MqttStatusWidget: React.FC = ({ systemStatus : 'No messages' return ( - - } - /> +
+ + } + /> + {onDebugClick && ( + + )} +
) } diff --git a/vision-system-remote/vite.config.ts b/vision-system-remote/vite.config.ts index 9e0a09a..ddbc9d5 100644 --- a/vision-system-remote/vite.config.ts +++ b/vision-system-remote/vite.config.ts @@ -27,6 +27,14 @@ export default defineConfig({ }, build: { target: 'esnext', + rollupOptions: { + output: { + // Add hash to filenames for cache busting + entryFileNames: 'assets/[name]-[hash].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash].[ext]', + }, + }, }, })