Update Docker configuration, enhance error handling, and improve logging

- Added health check to the camera management API service in docker-compose.yml for better container reliability.
- Updated installation scripts in Dockerfile to check for existing dependencies before installation, improving efficiency.
- Enhanced error handling in the USDAVisionSystem class to allow partial operation if some components fail to start, preventing immediate shutdown.
- Improved logging throughout the application, including more detailed error messages and critical error handling in the main loop.
- Refactored WebSocketManager and CameraMonitor classes to use debug logging for connection events, reducing log noise.
This commit is contained in:
salirezav
2025-12-03 17:23:31 -05:00
parent 2bce817b4e
commit 933d4417a5
30 changed files with 4314 additions and 220 deletions

View File

@@ -14,6 +14,8 @@ export function CameraPage({ cameraName }: CameraPageProps) {
const [mqttEvents, setMqttEvents] = useState<MqttEvent[]>([])
const [autoRecordingError, setAutoRecordingError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [retrying, setRetrying] = useState(false)
const imgRef = useRef<HTMLImageElement>(null)
const statusIntervalRef = useRef<number | null>(null)
const mqttEventsIntervalRef = useRef<number | null>(null)
@@ -121,19 +123,39 @@ export function CameraPage({ cameraName }: CameraPageProps) {
const loadCameraData = async () => {
try {
setLoading(true)
setError(null)
await Promise.all([loadCameraStatus(), loadCameraConfig()])
} catch (error) {
console.error('Error loading camera data:', error)
const errorMessage = error instanceof Error ? error.message : 'Failed to load camera data'
setError(errorMessage)
} finally {
setLoading(false)
}
}
const handleRetry = async () => {
setRetrying(true)
setError(null)
try {
await loadCameraData()
// Also retry streaming if it was previously active
if (streamStatus === 'error') {
await startStreaming()
}
} catch (error) {
console.error('Error retrying:', error)
} finally {
setRetrying(false)
}
}
const loadCameraStatus = async () => {
try {
const status = await visionApi.getCameraStatus(cameraName)
setCameraStatus(status)
setIsRecording(status.is_recording)
setError(null) // Clear error on successful load
// Update stream status based on camera status
if (status.status === 'streaming' || status.status === 'available') {
@@ -144,6 +166,11 @@ export function CameraPage({ cameraName }: CameraPageProps) {
}
} catch (error) {
console.error('Error loading camera status:', error)
// Only set error if we don't have status data at all
if (!cameraStatus) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load camera status'
setError(errorMessage)
}
}
}
@@ -151,8 +178,14 @@ export function CameraPage({ cameraName }: CameraPageProps) {
try {
const config = await visionApi.getCameraConfig(cameraName)
setCameraConfig(config)
setError(null) // Clear error on successful load
} catch (error) {
console.error('Error loading camera config:', error)
// Only set error if we don't have config data at all
if (!cameraConfig) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load camera configuration'
setError(errorMessage)
}
}
}
@@ -277,7 +310,7 @@ export function CameraPage({ cameraName }: CameraPageProps) {
}
}
if (loading) {
if (loading && !cameraStatus && !cameraConfig) {
return (
<div className="h-screen flex items-center justify-center bg-gray-900">
<div className="text-center text-white">
@@ -288,6 +321,51 @@ export function CameraPage({ cameraName }: CameraPageProps) {
)
}
if (error && !cameraStatus && !cameraConfig) {
return (
<div className="h-screen flex items-center justify-center bg-gray-900">
<div className="max-w-md w-full mx-4">
<div className="bg-red-50 border border-red-200 rounded-md p-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-red-800">Error loading camera</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error}</p>
</div>
<div className="mt-4">
<button
onClick={handleRetry}
disabled={retrying}
className="bg-red-100 px-4 py-2 rounded-md text-sm font-medium text-red-800 hover:bg-red-200 disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 flex items-center space-x-2"
>
{retrying ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-800"></div>
<span>Retrying...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Reload Module</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
const healthStatus = getHealthStatus()
return (

View File

@@ -217,7 +217,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
return <DataEntry />
case 'vision-system':
return (
<ErrorBoundary fallback={<div className="p-6">Failed to load vision system module. Please try again.</div>}>
<ErrorBoundary>
<Suspense fallback={<div className="p-6">Loading vision system module...</div>}>
<RemoteVisionSystem />
</Suspense>
@@ -225,7 +225,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
)
case 'scheduling':
return (
<ErrorBoundary fallback={<div className="p-6">Failed to load scheduling module. Please try again.</div>}>
<ErrorBoundary>
<Suspense fallback={<div className="p-6">Loading scheduling module...</div>}>
<RemoteScheduling user={user} currentRoute={currentRoute} />
</Suspense>
@@ -233,7 +233,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
)
case 'video-library':
return (
<ErrorBoundary fallback={<div className="p-6">Failed to load video module. Please try again.</div>}>
<ErrorBoundary>
<Suspense fallback={<div className="p-6">Loading video module...</div>}>
<RemoteVideoLibrary />
</Suspense>
@@ -312,7 +312,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
)}
</div>
<div
className={`flex-1 transition-all duration-300 ease-in-out bg-gray-50 dark:bg-gray-900 ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
className={`flex-1 transition-all duration-300 ease-in-out bg-gray-50 dark:bg-gray-900 flex flex-col min-h-0 ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
} ${isMobileOpen ? "ml-0" : ""}`}
>
<TopNavbar
@@ -323,7 +323,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
isSidebarOpen={isMobileOpen}
onNavigateToProfile={() => handleViewChange('profile')}
/>
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">
<div className="flex-1 min-h-0 overflow-hidden p-4 md:p-6">
{renderCurrentView()}
</div>
</div>

View File

@@ -109,7 +109,40 @@ export function DataEntry() {
return (
<div className="p-6">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-4">
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-red-800 dark:text-red-400">Error loading data</h3>
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
<p>{error}</p>
</div>
<div className="mt-4">
<button
onClick={loadData}
disabled={loading}
className="bg-red-100 dark:bg-red-900/30 px-4 py-2 rounded-md text-sm font-medium text-red-800 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/50 disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 flex items-center space-x-2"
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-800 dark:border-red-300"></div>
<span>Retrying...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Reload Module</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
)

View File

@@ -1,6 +1,11 @@
import { Component, ReactNode } from 'react'
type Props = { children: ReactNode, fallback?: ReactNode }
type Props = {
children: ReactNode
fallback?: ReactNode
onRetry?: () => void
showRetry?: boolean
}
type State = { hasError: boolean }
export class ErrorBoundary extends Component<Props, State> {
@@ -12,9 +17,48 @@ export class ErrorBoundary extends Component<Props, State> {
componentDidCatch() {}
handleRetry = () => {
this.setState({ hasError: false })
if (this.props.onRetry) {
this.props.onRetry()
}
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <div className="p-6">Something went wrong loading this section.</div>
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-red-800">Something went wrong loading this section</h3>
<div className="mt-2 text-sm text-red-700">
<p>An error occurred while loading this component. Please try reloading it.</p>
</div>
{(this.props.showRetry !== false) && (
<div className="mt-4">
<button
onClick={this.handleRetry}
className="bg-red-100 px-4 py-2 rounded-md text-sm font-medium text-red-800 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
Reload Module
</button>
</div>
)}
</div>
</div>
</div>
</div>
)
}
return this.props.children
}

View File

@@ -936,7 +936,6 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
// Create/update soaking record with repetition_id
await phaseManagement.createSoaking({
experiment_id: experimentId,
repetition_id: repId,
scheduled_start_time: soakingStart.toISOString(),
soaking_duration_minutes: soaking.soaking_duration_minutes
@@ -944,7 +943,6 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
// Create/update airdrying record with repetition_id
await phaseManagement.createAirdrying({
experiment_id: experimentId,
repetition_id: repId,
scheduled_start_time: airdryingStart.toISOString(),
duration_minutes: airdrying.duration_minutes
@@ -957,7 +955,6 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
if (phase?.cracking_machine_type_id) {
await phaseManagement.createCracking({
experiment_id: experimentId,
repetition_id: repId,
machine_type_id: phase.cracking_machine_type_id,
scheduled_start_time: crackingStart.toISOString()
@@ -999,8 +996,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
return (
<div className="p-6">
<div className="mb-6">
<div className="h-full flex flex-col overflow-hidden -m-4 md:-m-6">
<div className="p-6 flex-shrink-0">
<button
onClick={onBack}
className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mb-4"
@@ -1018,7 +1015,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="px-6 pb-6 flex-1 min-h-0 overflow-hidden">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 h-full flex flex-col min-h-0 overflow-hidden">
{error && (
<div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div>
)}
@@ -1033,7 +1031,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>
</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 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -1254,8 +1252,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
</div>
)}
{/* Week Calendar for selected conductors' availability */}
<div className="mt-6">
<div className="flex justify-between items-center mb-3">
<div className="mt-6 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex justify-between items-center mb-3 flex-shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
<div className="flex gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Marker Style:</span>
@@ -1297,7 +1295,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
</button>
</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 min-h-0 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative">
<DndProvider backend={HTML5Backend}>
<DnDCalendar
localizer={localizer}
@@ -1387,6 +1385,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
</div>
</div>
</div>
</div>
</div>
)

View File

@@ -80,8 +80,7 @@ export interface MachineType {
// Phase-specific interfaces
export interface Soaking {
id: string
experiment_id: string
repetition_id?: string | null
repetition_id: string
scheduled_start_time: string
actual_start_time?: string | null
soaking_duration_minutes: number
@@ -94,8 +93,7 @@ export interface Soaking {
export interface Airdrying {
id: string
experiment_id: string
repetition_id?: string | null
repetition_id: string
scheduled_start_time: string
actual_start_time?: string | null
duration_minutes: number
@@ -307,8 +305,7 @@ export interface UpdatePhaseDataRequest {
// Phase creation request interfaces
export interface CreateSoakingRequest {
experiment_id: string
repetition_id?: string
repetition_id: string
scheduled_start_time: string
soaking_duration_minutes: number
actual_start_time?: string
@@ -316,19 +313,17 @@ export interface CreateSoakingRequest {
}
export interface CreateAirdryingRequest {
experiment_id: string
repetition_id?: string
scheduled_start_time?: string // Will be auto-calculated from soaking if not provided
repetition_id: string
scheduled_start_time: string
duration_minutes: number
actual_start_time?: string
actual_end_time?: string
}
export interface CreateCrackingRequest {
experiment_id: string
repetition_id?: string
repetition_id: string
machine_type_id: string
scheduled_start_time?: string // Will be auto-calculated from airdrying if not provided
scheduled_start_time: string
actual_start_time?: string
actual_end_time?: string
}
@@ -798,11 +793,22 @@ export const phaseManagement = {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
if (!request.repetition_id) {
throw new Error('repetition_id is required')
}
const scheduledEndTime = new Date(new Date(request.scheduled_start_time).getTime() + request.soaking_duration_minutes * 60000).toISOString()
const { data, error } = await supabase
.from('soaking')
.insert({
...request,
.upsert({
repetition_id: request.repetition_id,
scheduled_start_time: request.scheduled_start_time,
soaking_duration_minutes: request.soaking_duration_minutes,
scheduled_end_time: scheduledEndTime,
created_by: user.id
}, {
onConflict: 'repetition_id'
})
.select()
.single()
@@ -824,10 +830,23 @@ export const phaseManagement = {
},
async getSoakingByExperimentId(experimentId: string): Promise<Soaking | null> {
// Get the first repetition for this experiment
const { data: repetitions, error: repsError } = await supabase
.from('experiment_repetitions')
.select('id')
.eq('experiment_id', experimentId)
.order('repetition_number', { ascending: true })
.limit(1)
if (repsError || !repetitions || repetitions.length === 0) {
return null
}
// Get soaking for the first repetition
const { data, error } = await supabase
.from('soaking')
.select('*')
.eq('experiment_id', experimentId)
.eq('repetition_id', repetitions[0].id)
.single()
if (error) {
@@ -856,11 +875,26 @@ export const phaseManagement = {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
if (!request.repetition_id) {
throw new Error('repetition_id is required')
}
if (!request.scheduled_start_time) {
throw new Error('scheduled_start_time is required')
}
const scheduledEndTime = new Date(new Date(request.scheduled_start_time).getTime() + request.duration_minutes * 60000).toISOString()
const { data, error } = await supabase
.from('airdrying')
.insert({
...request,
.upsert({
repetition_id: request.repetition_id,
scheduled_start_time: request.scheduled_start_time,
duration_minutes: request.duration_minutes,
scheduled_end_time: scheduledEndTime,
created_by: user.id
}, {
onConflict: 'repetition_id'
})
.select()
.single()
@@ -882,10 +916,23 @@ export const phaseManagement = {
},
async getAirdryingByExperimentId(experimentId: string): Promise<Airdrying | null> {
// Get the first repetition for this experiment
const { data: repetitions, error: repsError } = await supabase
.from('experiment_repetitions')
.select('id')
.eq('experiment_id', experimentId)
.order('repetition_number', { ascending: true })
.limit(1)
if (repsError || !repetitions || repetitions.length === 0) {
return null
}
// Get airdrying for the first repetition
const { data, error } = await supabase
.from('airdrying')
.select('*')
.eq('experiment_id', experimentId)
.eq('repetition_id', repetitions[0].id)
.single()
if (error) {
@@ -914,11 +961,23 @@ export const phaseManagement = {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
if (!request.repetition_id) {
throw new Error('repetition_id is required')
}
if (!request.scheduled_start_time) {
throw new Error('scheduled_start_time is required')
}
const { data, error } = await supabase
.from('cracking')
.insert({
...request,
.upsert({
repetition_id: request.repetition_id,
machine_type_id: request.machine_type_id,
scheduled_start_time: request.scheduled_start_time,
created_by: user.id
}, {
onConflict: 'repetition_id'
})
.select()
.single()