964 lines
42 KiB
TypeScript
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>
|
|
)
|
|
}
|