Files
usda-vision/management-dashboard-web-app/src/components/VisionSystem.tsx

964 lines
42 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo, memo, startTransition } from 'react'
import {
visionApi,
type SystemStatus,
type CameraStatus,
type MachineStatus,
type StorageStats,
type RecordingInfo,
type MqttStatus,
type MqttEventsResponse,
type MqttEvent,
formatBytes,
formatDuration,
formatUptime
} from '../lib/visionApi'
import { useAuth } from '../hooks/useAuth'
import { CameraConfigModal } from './CameraConfigModal'
import { CameraPreviewModal } from './CameraPreviewModal'
// Memoized components to prevent unnecessary re-renders
const SystemOverview = memo(({ systemStatus }: { systemStatus: SystemStatus }) => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.system_started ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{systemStatus.system_started ? 'Online' : 'Offline'}
</div>
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-semibold text-gray-900">System Status</div>
<div className="mt-1 text-sm text-gray-500">
Uptime: {formatUptime(systemStatus.uptime_seconds)}
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.mqtt_connected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{systemStatus.mqtt_connected ? 'Connected' : 'Disconnected'}
</div>
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-semibold text-gray-900">MQTT Status</div>
<div className="mt-1 text-sm text-gray-500">
Last message: {systemStatus.last_mqtt_message || 'Never'}
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{systemStatus.active_recordings} Active
</div>
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-semibold text-gray-900">Recordings</div>
<div className="mt-1 text-sm text-gray-500">
Total: {systemStatus.total_recordings}
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
{Object.keys(systemStatus.cameras).length} Cameras
</div>
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-semibold text-gray-900">Devices</div>
<div className="mt-1 text-sm text-gray-500">
{Object.keys(systemStatus.machines).length} Machines
</div>
</div>
</div>
</div>
</div>
))
const StorageOverview = memo(({ storageStats }: { storageStats: StorageStats }) => (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Storage</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Storage usage and file statistics
</p>
</div>
<div className="border-t border-gray-200 p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">{storageStats.total_files}</div>
<div className="text-sm text-gray-500">Total Files</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-green-600">{formatBytes(storageStats.total_size_bytes)}</div>
<div className="text-sm text-gray-500">Total Size</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600">{formatBytes(storageStats.disk_usage.free)}</div>
<div className="text-sm text-gray-500">Free Space</div>
</div>
</div>
{/* Disk Usage Bar */}
<div className="mb-6">
<div className="flex justify-between text-sm text-gray-600 mb-2">
<span>Disk Usage</span>
<span>{Math.round((storageStats.disk_usage.used / storageStats.disk_usage.total) * 100)}% used</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${(storageStats.disk_usage.used / storageStats.disk_usage.total) * 100}%` }}
></div>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>{formatBytes(storageStats.disk_usage.used)} used</span>
<span>{formatBytes(storageStats.disk_usage.total)} total</span>
</div>
</div>
{/* Per-Camera Statistics */}
{Object.keys(storageStats.cameras).length > 0 && (
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">Files by Camera</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(storageStats.cameras).map(([cameraName, stats]) => (
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">{cameraName}</h5>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Files:</span>
<span className="text-gray-900">{stats.file_count}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Size:</span>
<span className="text-gray-900">{formatBytes(stats.total_size_bytes)}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
))
const CamerasStatus = memo(({
systemStatus,
onConfigureCamera,
onStartRecording,
onStopRecording,
onPreviewCamera,
onStopStreaming
}: {
systemStatus: SystemStatus,
onConfigureCamera: (cameraName: string) => void,
onStartRecording: (cameraName: string) => Promise<void>,
onStopRecording: (cameraName: string) => Promise<void>,
onPreviewCamera: (cameraName: string) => void,
onStopStreaming: (cameraName: string) => Promise<void>
}) => {
const { isAdmin } = useAuth()
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<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="border-t border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-6">
{Object.entries(systemStatus.cameras).map(([cameraName, camera]) => {
const friendlyName = camera.device_info?.friendly_name
const hasDeviceInfo = !!camera.device_info
const hasSerial = !!camera.device_info?.serial_number
// Determine if camera is connected based on status
const isConnected = camera.status === 'available' || camera.status === 'connected'
const hasError = camera.status === 'error'
const statusText = camera.status || 'unknown'
return (
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-gray-900">
{friendlyName || cameraName}
{friendlyName && (
<span className="text-gray-500 text-sm font-normal ml-2">({cameraName})</span>
)}
</h4>
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isConnected ? 'bg-green-100 text-green-800' :
hasError ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}`}>
{isConnected ? 'Connected' : hasError ? 'Error' : 'Disconnected'}
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Status:</span>
<span className={`font-medium ${isConnected ? 'text-green-600' :
hasError ? 'text-yellow-600' :
'text-red-600'
}`}>
{statusText.charAt(0).toUpperCase() + statusText.slice(1)}
</span>
</div>
{camera.is_recording && (
<div className="flex justify-between">
<span className="text-gray-500">Recording:</span>
<span className="text-red-600 font-medium flex items-center">
<div className="w-2 h-2 bg-red-500 rounded-full mr-2 animate-pulse"></div>
Active
</span>
</div>
)}
{hasDeviceInfo && (
<>
{camera.device_info.model && (
<div className="flex justify-between">
<span className="text-gray-500">Model:</span>
<span className="text-gray-900">{camera.device_info.model}</span>
</div>
)}
{hasSerial && (
<div className="flex justify-between">
<span className="text-gray-500">Serial:</span>
<span className="text-gray-900 font-mono text-xs">{camera.device_info.serial_number}</span>
</div>
)}
{camera.device_info.firmware_version && (
<div className="flex justify-between">
<span className="text-gray-500">Firmware:</span>
<span className="text-gray-900 font-mono text-xs">{camera.device_info.firmware_version}</span>
</div>
)}
</>
)}
{camera.last_frame_time && (
<div className="flex justify-between">
<span className="text-gray-500">Last Frame:</span>
<span className="text-gray-900">{new Date(camera.last_frame_time).toLocaleTimeString()}</span>
</div>
)}
{camera.frame_rate && (
<div className="flex justify-between">
<span className="text-gray-500">Frame Rate:</span>
<span className="text-gray-900">{camera.frame_rate.toFixed(1)} fps</span>
</div>
)}
{camera.last_checked && (
<div className="flex justify-between">
<span className="text-gray-500">Last Checked:</span>
<span className="text-gray-900">{new Date(camera.last_checked).toLocaleTimeString()}</span>
</div>
)}
{camera.current_recording_file && (
<div className="flex justify-between">
<span className="text-gray-500">Recording File:</span>
<span className="text-gray-900 truncate ml-2">{camera.current_recording_file}</span>
</div>
)}
{camera.last_error && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded">
<div className="text-red-800 text-xs">
<strong>Error:</strong> {camera.last_error}
</div>
</div>
)}
{/* Camera Control Buttons */}
<div className="mt-3 pt-3 border-t border-gray-200 space-y-2">
{/* Recording Controls */}
<div className="flex space-x-2">
{!camera.is_recording ? (
<button
onClick={() => onStartRecording(cameraName)}
disabled={!isConnected}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
? 'text-green-600 bg-green-50 border border-green-200 hover:bg-green-100 focus:ring-green-500'
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
}`}
>
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h8m-9-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Start Recording
</button>
) : (
<button
onClick={() => onStopRecording(cameraName)}
className="flex-1 px-3 py-2 text-sm font-medium text-red-600 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9h6v6H9z" />
</svg>
Stop Recording
</button>
)}
</div>
{/* Preview and Streaming Controls */}
<div className="flex space-x-2">
<button
onClick={() => onPreviewCamera(cameraName)}
disabled={!isConnected}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
? 'text-blue-600 bg-blue-50 border border-blue-200 hover:bg-blue-100 focus:ring-blue-500'
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
}`}
>
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Preview
</button>
<button
onClick={() => onStopStreaming(cameraName)}
disabled={!isConnected}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
? 'text-orange-600 bg-orange-50 border border-orange-200 hover:bg-orange-100 focus:ring-orange-500'
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
}`}
>
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Stop Streaming
</button>
</div>
</div>
{/* Admin Configuration Button */}
{isAdmin() && (
<div className="mt-3 pt-3 border-t border-gray-200">
<button
onClick={() => onConfigureCamera(cameraName)}
className="w-full px-3 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Configure Camera
</button>
</div>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
)
})
const RecentRecordings = memo(({ recordings, systemStatus }: { recordings: Record<string, RecordingInfo>, systemStatus: SystemStatus | null }) => (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Recent Recordings</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Latest recording sessions
</p>
</div>
<div className="border-t border-gray-200">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Camera
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Filename
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Size
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Started
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{Object.entries(recordings).slice(0, 10).map(([recordingId, recording]) => {
const camera = systemStatus?.cameras[recording.camera_name]
const displayName = camera?.device_info?.friendly_name || recording.camera_name
return (
<tr key={recordingId}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{displayName}
{camera?.device_info?.friendly_name && (
<div className="text-xs text-gray-500">({recording.camera_name})</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-mono">
{recording.filename}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${recording.status === 'recording' ? 'bg-red-100 text-red-800' :
recording.status === 'completed' ? 'bg-green-100 text-green-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{recording.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{recording.duration_seconds ? formatDuration(recording.duration_seconds) : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{recording.file_size_bytes ? formatBytes(recording.file_size_bytes) : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{new Date(recording.start_time).toLocaleString()}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
))
export function VisionSystem() {
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
const [storageStats, setStorageStats] = useState<StorageStats | null>(null)
const [recordings, setRecordings] = useState<Record<string, RecordingInfo>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
const [refreshInterval, setRefreshInterval] = useState(5000) // 5 seconds default
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null)
const [mqttStatus, setMqttStatus] = useState<MqttStatus | null>(null)
const [mqttEvents, setMqttEvents] = useState<MqttEvent[]>([])
// Camera configuration modal state
const [configModalOpen, setConfigModalOpen] = useState(false)
const [selectedCamera, setSelectedCamera] = useState<string | null>(null)
const [notification, setNotification] = useState<{ type: 'success' | 'error', message: string } | null>(null)
// Camera preview modal state
const [previewModalOpen, setPreviewModalOpen] = useState(false)
const [previewCamera, setPreviewCamera] = useState<string | null>(null)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const clearAutoRefresh = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
const startAutoRefresh = useCallback(() => {
clearAutoRefresh()
if (autoRefreshEnabled && refreshInterval > 0) {
intervalRef.current = setInterval(fetchData, refreshInterval)
}
}, [autoRefreshEnabled, refreshInterval])
useEffect(() => {
fetchData()
startAutoRefresh()
return clearAutoRefresh
}, [startAutoRefresh])
useEffect(() => {
startAutoRefresh()
}, [autoRefreshEnabled, refreshInterval, startAutoRefresh])
const fetchData = useCallback(async (showRefreshIndicator = true) => {
try {
setError(null)
if (!systemStatus) {
setLoading(true)
} else if (showRefreshIndicator) {
setRefreshing(true)
}
const [statusData, storageData, recordingsData, mqttStatusData, mqttEventsData] = await Promise.all([
visionApi.getSystemStatus(),
visionApi.getStorageStats(),
visionApi.getRecordings(),
visionApi.getMqttStatus().catch(err => {
console.warn('Failed to fetch MQTT status:', err)
return null
}),
visionApi.getMqttEvents(10).catch(err => {
console.warn('Failed to fetch MQTT events:', err)
return { events: [], total_events: 0, last_updated: '' }
})
])
// If cameras don't have device_info, try to fetch individual camera status
if (statusData.cameras) {
const camerasNeedingInfo = Object.entries(statusData.cameras)
.filter(([_, camera]) => !camera.device_info?.friendly_name)
.map(([cameraName, _]) => cameraName)
if (camerasNeedingInfo.length > 0) {
console.log('Fetching individual camera info for:', camerasNeedingInfo)
try {
const individualCameraData = await Promise.all(
camerasNeedingInfo.map(cameraName =>
visionApi.getCameraStatus(cameraName).catch(err => {
console.warn(`Failed to get individual status for ${cameraName}:`, err)
return null
})
)
)
// Merge the individual camera data back into statusData
camerasNeedingInfo.forEach((cameraName, index) => {
const individualData = individualCameraData[index]
if (individualData && individualData.device_info) {
statusData.cameras[cameraName] = {
...statusData.cameras[cameraName],
device_info: individualData.device_info
}
}
})
} catch (err) {
console.warn('Failed to fetch individual camera data:', err)
}
}
}
// Batch state updates to minimize re-renders using startTransition for non-urgent updates
const updateTime = new Date()
// Use startTransition for non-urgent state updates to keep the UI responsive
startTransition(() => {
setSystemStatus(statusData)
setStorageStats(storageData)
setRecordings(recordingsData)
setLastUpdateTime(updateTime)
// Update MQTT status and events
if (mqttStatusData) {
setMqttStatus(mqttStatusData)
}
if (mqttEventsData && mqttEventsData.events) {
setMqttEvents(mqttEventsData.events)
}
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch vision system data')
console.error('Vision system fetch error:', err)
// Don't disable auto-refresh on errors, just log them
} finally {
setLoading(false)
setRefreshing(false)
}
}, [systemStatus])
// Camera configuration handlers
const handleConfigureCamera = (cameraName: string) => {
setSelectedCamera(cameraName)
setConfigModalOpen(true)
}
const handleConfigSuccess = (message: string) => {
setNotification({ type: 'success', message })
setTimeout(() => setNotification(null), 5000)
}
const handleConfigError = (message: string) => {
setNotification({ type: 'error', message })
setTimeout(() => setNotification(null), 5000)
}
// Recording control handlers
const handleStartRecording = 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}` })
// Refresh data to update recording status
fetchData(false)
} else {
setNotification({ type: 'error', message: `Failed to start recording: ${result.message}` })
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
setNotification({ type: 'error', message: `Error starting recording: ${errorMessage}` })
}
}
const handleStopRecording = async (cameraName: string) => {
try {
const result = await visionApi.stopRecording(cameraName)
if (result.success) {
setNotification({ type: 'success', message: `Recording stopped: ${result.filename}` })
// Refresh data to update recording status
fetchData(false)
} else {
setNotification({ type: 'error', message: `Failed to stop recording: ${result.message}` })
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
setNotification({ type: 'error', message: `Error stopping recording: ${errorMessage}` })
}
}
const handlePreviewCamera = (cameraName: string) => {
setPreviewCamera(cameraName)
setPreviewModalOpen(true)
}
const handleStopStreaming = async (cameraName: string) => {
try {
const result = await visionApi.stopStream(cameraName)
if (result.success) {
setNotification({ type: 'success', message: `Streaming stopped for ${cameraName}` })
// Refresh data to update camera status
fetchData(false)
} else {
setNotification({ type: 'error', message: `Failed to stop streaming: ${result.message}` })
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
setNotification({ type: 'error', message: `Error stopping stream: ${errorMessage}` })
}
}
const getStatusColor = (status: string, isRecording: boolean = false) => {
// If camera is recording, always show red regardless of status
if (isRecording) {
return 'text-red-600 bg-red-100'
}
switch (status.toLowerCase()) {
case 'available':
case 'connected':
case 'healthy':
case 'on':
return 'text-green-600 bg-green-100'
case 'disconnected':
case 'off':
case 'failed':
return 'text-red-600 bg-red-100'
case 'error':
case 'warning':
case 'degraded':
return 'text-yellow-600 bg-yellow-100'
default:
return 'text-yellow-600 bg-yellow-100'
}
}
const getMachineStateColor = (state: string) => {
switch (state.toLowerCase()) {
case 'on':
case 'running':
return 'text-green-600 bg-green-100'
case 'off':
case 'stopped':
return 'text-gray-600 bg-gray-100'
default:
return 'text-yellow-600 bg-yellow-100'
}
}
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"></div>
<p className="mt-4 text-gray-600">Loading vision system data...</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={() => fetchData(true)}
disabled={refreshing}
className="bg-red-100 px-3 py-2 rounded-md text-sm font-medium text-red-800 hover:bg-red-200 disabled:opacity-50"
>
{refreshing ? 'Retrying...' : 'Try Again'}
</button>
</div>
</div>
</div>
</div>
</div>
)
}
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>
{lastUpdateTime && (
<p className={`mt-1 text-sm text-gray-500 flex items-center space-x-2 ${refreshing ? 'animate-pulse' : ''}`}>
<span>Last updated: {lastUpdateTime.toLocaleTimeString()}</span>
{refreshing && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<span className="animate-spin rounded-full h-3 w-3 border-b border-blue-600 mr-1 inline-block"></span>
Updating...
</span>
)}
{autoRefreshEnabled && !refreshing && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Auto-refresh: {refreshInterval / 1000}s
</span>
)}
</p>
)}
</div>
<div className="flex items-center space-x-4">
{/* Auto-refresh controls */}
<div className="flex items-center space-x-2">
<label className="flex items-center space-x-2 text-sm text-gray-600">
<input
type="checkbox"
checked={autoRefreshEnabled}
onChange={(e) => setAutoRefreshEnabled(e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>Auto-refresh</span>
</label>
{autoRefreshEnabled && (
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
className="text-sm border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
>
<option value={2000}>2s</option>
<option value={5000}>5s</option>
<option value={10000}>10s</option>
<option value={30000}>30s</option>
<option value={60000}>1m</option>
</select>
)}
</div>
{/* Refresh indicator and button */}
<div className="flex items-center space-x-2">
{refreshing && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600"></div>
)}
<button
onClick={() => fetchData(true)}
disabled={refreshing}
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 disabled:opacity-50"
>
Refresh
</button>
</div>
</div>
</div>
{/* System Overview */}
{systemStatus && <SystemOverview systemStatus={systemStatus} />}
{/* Cameras Status */}
{systemStatus && (
<CamerasStatus
systemStatus={systemStatus}
onConfigureCamera={handleConfigureCamera}
onStartRecording={handleStartRecording}
onStopRecording={handleStopRecording}
onPreviewCamera={handlePreviewCamera}
onStopStreaming={handleStopStreaming}
/>
)}
{/* Machines Status */}
{systemStatus && Object.keys(systemStatus.machines).length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Machines</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Current status of all machines in the system
</p>
</div>
<div className="border-t border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-6">
{Object.entries(systemStatus.machines).map(([machineName, machine]) => (
<div key={machineName} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-gray-900 capitalize">
{machineName.replace(/_/g, ' ')}
</h4>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMachineStateColor(machine.state)}`}>
{machine.state}
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Last updated:</span>
<span className="text-gray-900">{new Date(machine.last_updated).toLocaleTimeString()}</span>
</div>
{machine.last_message && (
<div className="flex justify-between">
<span className="text-gray-500">Last message:</span>
<span className="text-gray-900">{machine.last_message}</span>
</div>
)}
{machine.mqtt_topic && (
<div className="flex justify-between">
<span className="text-gray-500">MQTT topic:</span>
<span className="text-gray-900 text-xs font-mono">{machine.mqtt_topic}</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Storage Statistics */}
{storageStats && <StorageOverview storageStats={storageStats} />}
{/* Recent Recordings */}
{Object.keys(recordings).length > 0 && <RecentRecordings recordings={recordings} systemStatus={systemStatus} />}
{/* Camera Configuration Modal */}
{selectedCamera && (
<CameraConfigModal
cameraName={selectedCamera}
isOpen={configModalOpen}
onClose={() => {
setConfigModalOpen(false)
setSelectedCamera(null)
}}
onSuccess={handleConfigSuccess}
onError={handleConfigError}
/>
)}
{/* Camera Preview Modal */}
{previewCamera && (
<CameraPreviewModal
cameraName={previewCamera}
isOpen={previewModalOpen}
onClose={() => {
setPreviewModalOpen(false)
setPreviewCamera(null)
}}
/>
)}
{/* Notification */}
{notification && (
<div className={`fixed top-4 right-4 z-50 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>
)}
</div>
)
}