- 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.
530 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|
|
|