Files
usda-vision/vision-system-remote/src/App.tsx
salirezav b3a94d2d4f 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.
2025-12-01 15:30:10 -05:00

530 lines
19 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react'
import { useWebSocket } from './hooks/useWebSocket'
import { visionApi, type SystemStatus, type CameraStatus, type RecordingInfo } from './services/api'
import { SystemHealthWidget } from './widgets/SystemHealthWidget'
import { MqttStatusWidget } from './widgets/MqttStatusWidget'
import { RecordingsCountWidget } from './widgets/RecordingsCountWidget'
import { CameraCountWidget } from './widgets/CameraCountWidget'
import { CameraCard } from './components/CameraCard'
import { CameraPreviewModal } from './components/CameraPreviewModal'
import { CameraConfigModal } from './components/CameraConfigModal'
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`
}
export default function App() {
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
const [cameras, setCameras] = useState<Record<string, CameraStatus>>({})
const [recordings, setRecordings] = useState<Record<string, RecordingInfo>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdate, setLastUpdate] = useState<Date | null>(null)
const [notification, setNotification] = useState<{ type: 'success' | 'error', message: string } | null>(null)
// Modal states
const [previewModalOpen, setPreviewModalOpen] = useState(false)
const [previewCamera, setPreviewCamera] = useState<string | null>(null)
const [configModalOpen, setConfigModalOpen] = useState(false)
const [selectedCamera, setSelectedCamera] = useState<string | null>(null)
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
// WebSocket connection
const { isConnected, subscribe } = useWebSocket(getWebSocketUrl())
// Fetch initial data
const fetchInitialData = useCallback(async () => {
try {
setError(null)
const [status, camerasData, recordingsData] = await Promise.all([
visionApi.getSystemStatus(),
visionApi.getCameras(),
visionApi.getRecordings(),
])
setSystemStatus(status)
setCameras(camerasData)
setRecordings(recordingsData)
setLastUpdate(new Date())
} catch (err) {
// Don't set error state - let widgets show API is unavailable
// Keep existing state so UI can still render
console.error('Failed to fetch initial data:', err)
setLastUpdate(new Date()) // Update timestamp even on error to show we tried
} finally {
setLoading(false)
}
}, [])
// Set up WebSocket subscriptions for real-time updates
useEffect(() => {
const unsubscribeFunctions: Array<() => void> = []
// Subscribe to camera status changes
unsubscribeFunctions.push(
subscribe('camera_status_changed', (event) => {
const { camera_name, status, is_recording } = event.data
setCameras((prev) => ({
...prev,
[camera_name]: {
...prev[camera_name],
status,
is_recording,
last_checked: new Date().toISOString(),
},
}))
setLastUpdate(new Date())
})
)
// Subscribe to recording started events
unsubscribeFunctions.push(
subscribe('recording_started', (event) => {
const { camera_name } = event.data
setCameras((prev) => ({
...prev,
[camera_name]: {
...prev[camera_name],
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())
})
)
// Subscribe to recording stopped events
unsubscribeFunctions.push(
subscribe('recording_stopped', (event) => {
const { camera_name } = event.data
setCameras((prev) => ({
...prev,
[camera_name]: {
...prev[camera_name],
is_recording: false,
},
}))
// Refresh recordings and system status
Promise.all([
visionApi.getRecordings(),
visionApi.getSystemStatus(),
]).then(([recordingsData, statusData]) => {
setRecordings(recordingsData)
setSystemStatus(statusData)
}).catch(console.error)
setLastUpdate(new Date())
})
)
// Subscribe to system status changes
unsubscribeFunctions.push(
subscribe('system_status_changed', () => {
visionApi.getSystemStatus().then(setSystemStatus).catch(console.error)
setLastUpdate(new Date())
})
)
// Subscribe to MQTT status changes
unsubscribeFunctions.push(
subscribe('mqtt_status_changed', () => {
visionApi.getSystemStatus().then(setSystemStatus).catch(console.error)
setLastUpdate(new Date())
})
)
return () => {
unsubscribeFunctions.forEach((unsub) => unsub())
}
}, [subscribe])
// Fetch initial data on mount
useEffect(() => {
fetchInitialData()
}, [fetchInitialData])
// Camera action handlers
const handleStartRecording = useCallback(async (cameraName: string) => {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `manual_${cameraName}_${timestamp}.mp4`
const result = await visionApi.startRecording(cameraName, filename)
if (result.success) {
setNotification({ type: 'success', message: `Recording started: ${result.filename}` })
// Immediately update state optimistically (UI updates instantly)
setCameras((prev) => ({
...prev,
[cameraName]: {
...prev[cameraName],
is_recording: true,
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)
}, 500)
} else {
setNotification({ type: 'error', message: `Failed: ${result.message}` })
}
} catch (err) {
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Unknown error' })
}
}, [])
const handleStopRecording = useCallback(async (cameraName: string) => {
try {
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,
[cameraName]: {
...prev[cameraName],
is_recording: false,
current_recording_file: null,
},
}))
// Refresh camera status from API as backup (in case WebSocket is delayed)
setTimeout(() => {
visionApi.getCameras().then(setCameras).catch(console.error)
}, 500)
} else {
setNotification({ type: 'error', message: `Failed: ${result.message}` })
}
} catch (err) {
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Unknown error' })
}
}, [])
const handlePreviewModal = useCallback((cameraName: string) => {
setPreviewCamera(cameraName)
setPreviewModalOpen(true)
// The modal will start streaming and notify us via onStreamStarted callback
}, [])
const handlePreviewNewWindow = useCallback(async (cameraName: string) => {
try {
// Start streaming before opening new window
const result = await visionApi.startStream(cameraName)
if (result.success) {
// Immediately update camera status to show "Stop Streaming" button
setCameras((prev) => ({
...prev,
[cameraName]: {
...prev[cameraName],
status: 'streaming',
},
}))
// Open camera stream in new window/tab
const streamUrl = visionApi.getStreamUrl(cameraName)
window.open(streamUrl, '_blank')
} else {
setNotification({ type: 'error', message: `Failed to start stream: ${result.message}` })
}
} catch (err) {
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Failed to start stream' })
}
}, [])
const handleConfigure = useCallback((cameraName: string) => {
setSelectedCamera(cameraName)
setConfigModalOpen(true)
}, [])
const handleRestart = useCallback(async (cameraName: string) => {
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
setTimeout(() => {
visionApi.getCameras().then(setCameras).catch(console.error)
visionApi.getSystemStatus().then(setSystemStatus).catch(console.error)
}, 2000) // Wait 2 seconds for camera to reinitialize
} else {
setNotification({ type: 'error', message: `Failed: ${result.message}` })
}
} catch (err) {
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Unknown error' })
}
}, [])
const handleStopStreaming = useCallback(async (cameraName: string) => {
try {
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,
[cameraName]: {
...prev[cameraName],
status: 'available',
},
}))
// Refresh camera status from API as backup
setTimeout(() => {
visionApi.getCameras().then(setCameras).catch(console.error)
}, 500)
} else {
setNotification({ type: 'error', message: `Failed: ${result.message}` })
}
} catch (err) {
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Unknown error' })
}
}, [])
// Auto-hide notifications
useEffect(() => {
if (notification) {
const timer = setTimeout(() => setNotification(null), 5000)
return () => clearTimeout(timer)
}
}, [notification])
if (loading) {
return (
<div className="p-6">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto" />
<p className="mt-4 text-gray-600">Loading vision system...</p>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error loading vision system</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error}</p>
</div>
<div className="mt-4">
<button
onClick={fetchInitialData}
className="bg-red-100 px-3 py-2 rounded-md text-sm font-medium text-red-800 hover:bg-red-200"
>
Retry
</button>
</div>
</div>
</div>
</div>
</div>
)
}
const cameraCount = Object.keys(cameras).length
const machineCount = systemStatus ? Object.keys(systemStatus.machines).length : 0
const activeRecordings = systemStatus?.active_recordings ?? 0
// Fix: Use recordings object length instead of total_recordings (which may be incorrect)
const totalRecordings = Object.keys(recordings).length
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Vision System</h1>
<p className="mt-2 text-gray-600">Monitor cameras, machines, and recording status</p>
{lastUpdate && (
<p className="mt-1 text-sm text-gray-500 flex items-center space-x-2">
<span>Last updated: {lastUpdate.toLocaleTimeString()}</span>
{isConnected ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<span className="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse" />
Live Updates
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Polling Mode
</span>
)}
</p>
)}
</div>
</div>
{/* Status Widgets */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<SystemHealthWidget systemStatus={systemStatus} />
<MqttStatusWidget
systemStatus={systemStatus}
onDebugClick={() => setDebugPanelOpen(true)}
/>
<RecordingsCountWidget active={activeRecordings} total={totalRecordings} />
<CameraCountWidget cameraCount={cameraCount} machineCount={machineCount} />
</div>
{/* Cameras Grid */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h3 className="text-lg leading-6 font-medium text-gray-900">Cameras</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Current status of all cameras in the system
</p>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Object.entries(cameras).map(([cameraName, camera]) => (
<CameraCard
key={cameraName}
cameraName={cameraName}
camera={camera}
onStartRecording={handleStartRecording}
onStopRecording={handleStopRecording}
onPreviewModal={handlePreviewModal}
onPreviewNewWindow={handlePreviewNewWindow}
onStopStreaming={handleStopStreaming}
onConfigure={handleConfigure}
onRestart={handleRestart}
/>
))}
</div>
</div>
</div>
{/* Notification */}
{notification && (
<div
className={`fixed top-4 right-4 z-[999999] p-4 rounded-md shadow-lg ${notification.type === 'success'
? 'bg-green-50 border border-green-200 text-green-800'
: 'bg-red-50 border border-red-200 text-red-800'
}`}
>
<div className="flex items-center">
<div className="flex-shrink-0">
{notification.type === 'success' ? (
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="ml-3">
<p className="text-sm font-medium">{notification.message}</p>
</div>
<div className="ml-auto pl-3">
<button
onClick={() => setNotification(null)}
className={`inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2 ${notification.type === 'success'
? 'text-green-500 hover:bg-green-100 focus:ring-green-600'
: 'text-red-500 hover:bg-red-100 focus:ring-red-600'
}`}
>
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
)}
{/* Camera Preview Modal */}
{previewCamera && (
<CameraPreviewModal
cameraName={previewCamera}
isOpen={previewModalOpen}
onClose={() => {
setPreviewModalOpen(false)
setPreviewCamera(null)
}}
onStreamStarted={() => {
// Update camera status when streaming starts
setCameras((prev) => ({
...prev,
[previewCamera]: {
...prev[previewCamera],
status: 'streaming',
},
}))
}}
onStreamStopped={() => {
// Update camera status when streaming stops
setCameras((prev) => ({
...prev,
[previewCamera]: {
...prev[previewCamera],
status: 'available',
},
}))
}}
/>
)}
{/* Camera Configuration Modal */}
{selectedCamera && (
<CameraConfigModal
cameraName={selectedCamera}
isOpen={configModalOpen}
onClose={() => {
setConfigModalOpen(false)
setSelectedCamera(null)
}}
onSuccess={(message) => {
setNotification({ type: 'success', message })
// Refresh camera status
visionApi.getCameras().then(setCameras).catch(console.error)
}}
onError={(error) => {
setNotification({ type: 'error', message: error })
}}
/>
)}
{/* MQTT Debug Panel */}
<MqttDebugPanel
isOpen={debugPanelOpen}
onClose={() => setDebugPanelOpen(false)}
/>
</div>
)
}