feat: Add CameraPreviewModal component for live camera streaming
feat: Implement useAuth hook for user authentication management feat: Create useAutoRecording hook for managing automatic recording functionality feat: Develop AutoRecordingManager to handle automatic recording based on MQTT events test: Add test script to verify camera configuration API fix test: Create HTML page for testing camera configuration API and auto-recording fields
This commit is contained in:
162
src/components/AutoRecordingStatus.tsx
Normal file
162
src/components/AutoRecordingStatus.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { memo, useState, useEffect } from 'react'
|
||||
import { visionApi, type AutoRecordingStatusResponse } from '../lib/visionApi'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
const AutoRecordingStatus = memo(() => {
|
||||
const { isAdmin } = useAuth()
|
||||
const isAdminUser = isAdmin()
|
||||
const [status, setStatus] = useState<AutoRecordingStatusResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Fetch auto-recording status
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const statusData = await visionApi.getAutoRecordingStatus()
|
||||
setStatus(statusData)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch auto-recording status'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to fetch auto-recording status:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch status on mount and set up polling
|
||||
useEffect(() => {
|
||||
if (!isAdminUser) {
|
||||
return
|
||||
}
|
||||
|
||||
fetchStatus()
|
||||
const interval = setInterval(fetchStatus, 10000) // Poll every 10 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [isAdminUser])
|
||||
|
||||
// Only show to admins
|
||||
if (!isAdminUser) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Auto-Recording System</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Server-side automatic recording based on machine state changes
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${status?.running ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{status?.running ? 'Running' : 'Stopped'}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
className="bg-indigo-600 text-white px-3 py-1 rounded-md text-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-3 border-t border-gray-200">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<p className="text-red-800 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-3">System Status</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">System Running:</span>
|
||||
<span className={`font-medium ${status.running ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{status.running ? 'YES' : 'NO'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Auto-Recording Enabled:</span>
|
||||
<span className={`font-medium ${status.auto_recording_enabled ? 'text-green-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{status.auto_recording_enabled ? 'YES' : 'NO'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Enabled Cameras:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{status.enabled_cameras.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Retry Queue:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{Object.keys(status.retry_queue).length} items
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.enabled_cameras.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h5 className="text-sm font-medium text-gray-900 mb-2">Enabled Cameras:</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status.enabled_cameras.map((camera) => (
|
||||
<span
|
||||
key={camera}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
{camera}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(status.retry_queue).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h5 className="text-sm font-medium text-gray-900 mb-2">Retry Queue:</h5>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(status.retry_queue).map(([camera, retryInfo]) => (
|
||||
<div key={camera} className="text-xs text-gray-600 bg-yellow-50 p-2 rounded">
|
||||
<strong>{camera}:</strong> {JSON.stringify(retryInfo)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status && !loading && !error && (
|
||||
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>Auto-recording status not available</p>
|
||||
<p className="text-sm mt-1">Click "Refresh" to fetch the current status</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AutoRecordingStatus.displayName = 'AutoRecordingStatus'
|
||||
|
||||
export { AutoRecordingStatus }
|
||||
193
src/components/AutoRecordingTest.tsx
Normal file
193
src/components/AutoRecordingTest.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Auto-Recording Test Component
|
||||
*
|
||||
* This component provides a testing interface for the auto-recording functionality.
|
||||
* It allows admins to simulate MQTT events and verify auto-recording behavior.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { visionApi } from '../lib/visionApi'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
interface TestEvent {
|
||||
machine: string
|
||||
state: 'on' | 'off'
|
||||
timestamp: Date
|
||||
result?: string
|
||||
}
|
||||
|
||||
export function AutoRecordingTest() {
|
||||
const { isAdmin } = useAuth()
|
||||
const [testEvents, setTestEvents] = useState<TestEvent[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
if (!isAdmin()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const simulateEvent = async (machine: string, state: 'on' | 'off') => {
|
||||
setIsLoading(true)
|
||||
|
||||
const event: TestEvent = {
|
||||
machine,
|
||||
state,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
try {
|
||||
// Map machines to their corresponding cameras
|
||||
const machineToCamera: Record<string, string> = {
|
||||
'blower_separator': 'camera1', // camera1 is for blower separator
|
||||
'vibratory_conveyor': 'camera2' // camera2 is for conveyor
|
||||
}
|
||||
|
||||
const cameraName = machineToCamera[machine]
|
||||
if (!cameraName) {
|
||||
event.result = `❌ Error: No camera mapped for machine ${machine}`
|
||||
setTestEvents(prev => [event, ...prev.slice(0, 9)])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (state === 'on') {
|
||||
// Simulate starting recording on the correct camera
|
||||
const result = await visionApi.startRecording(cameraName, {
|
||||
filename: `test_auto_${machine}_${Date.now()}.avi`
|
||||
})
|
||||
event.result = result.success ? `✅ Recording started on ${cameraName}: ${result.filename}` : `❌ Failed: ${result.message}`
|
||||
} else {
|
||||
// Simulate stopping recording on the correct camera
|
||||
const result = await visionApi.stopRecording(cameraName)
|
||||
event.result = result.success ? `⏹️ Recording stopped on ${cameraName} (${result.duration_seconds}s)` : `❌ Failed: ${result.message}`
|
||||
}
|
||||
} catch (error) {
|
||||
event.result = `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
}
|
||||
|
||||
setTestEvents(prev => [event, ...prev.slice(0, 9)]) // Keep last 10 events
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const clearEvents = () => {
|
||||
setTestEvents([])
|
||||
}
|
||||
|
||||
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">Auto-Recording Test</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Simulate machine state changes to test auto-recording functionality
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
|
||||
<div className="space-y-4">
|
||||
{/* Test Controls */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-3">Simulate Machine Events</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<button
|
||||
onClick={() => simulateEvent('vibratory_conveyor', 'on')}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 text-white px-3 py-2 rounded-md text-sm hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Conveyor ON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => simulateEvent('vibratory_conveyor', 'off')}
|
||||
disabled={isLoading}
|
||||
className="bg-red-600 text-white px-3 py-2 rounded-md text-sm hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Conveyor OFF
|
||||
</button>
|
||||
<button
|
||||
onClick={() => simulateEvent('blower_separator', 'on')}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 text-white px-3 py-2 rounded-md text-sm hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Blower ON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => simulateEvent('blower_separator', 'off')}
|
||||
disabled={isLoading}
|
||||
className="bg-red-600 text-white px-3 py-2 rounded-md text-sm hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Blower OFF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Button */}
|
||||
{testEvents.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={clearEvents}
|
||||
className="bg-gray-600 text-white px-3 py-2 rounded-md text-sm hover:bg-gray-700"
|
||||
>
|
||||
Clear Events
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Results */}
|
||||
{testEvents.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-3">Test Results</h4>
|
||||
<div className="space-y-2">
|
||||
{testEvents.map((event, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{event.machine.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${event.state === 'on'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{event.state.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{event.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{event.result && (
|
||||
<div className="mt-2 text-sm text-gray-700">
|
||||
{event.result}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-2">Testing Instructions</h4>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>1. Ensure auto-recording is enabled for cameras in their configuration</li>
|
||||
<li>2. Start the auto-recording manager in the Vision System page</li>
|
||||
<li>3. Click the buttons above to simulate machine state changes</li>
|
||||
<li>4. Verify that recordings start/stop automatically</li>
|
||||
<li>5. Check the storage section for auto-generated recording files</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Expected Behavior */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Expected Behavior</h4>
|
||||
<div className="text-sm text-gray-700 space-y-1">
|
||||
<div><strong>Conveyor ON:</strong> Camera2 should start recording automatically</div>
|
||||
<div><strong>Conveyor OFF:</strong> Camera2 should stop recording automatically</div>
|
||||
<div><strong>Blower ON:</strong> Camera1 should start recording automatically</div>
|
||||
<div><strong>Blower OFF:</strong> Camera1 should stop recording automatically</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
587
src/components/CameraConfigModal.tsx
Normal file
587
src/components/CameraConfigModal.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { visionApi, type CameraConfig, type CameraConfigUpdate } from '../lib/visionApi'
|
||||
|
||||
interface CameraConfigModalProps {
|
||||
cameraName: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSuccess?: (message: string) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onError }: CameraConfigModalProps) {
|
||||
const [config, setConfig] = useState<CameraConfig | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [originalConfig, setOriginalConfig] = useState<CameraConfig | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && cameraName) {
|
||||
loadConfig()
|
||||
}
|
||||
}, [isOpen, cameraName])
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const configData = await visionApi.getCameraConfig(cameraName)
|
||||
setConfig(configData)
|
||||
setOriginalConfig(configData)
|
||||
setHasChanges(false)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load camera configuration'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateSetting = (key: keyof CameraConfigUpdate, value: number | boolean) => {
|
||||
if (!config) return
|
||||
|
||||
const newConfig = { ...config, [key]: value }
|
||||
setConfig(newConfig)
|
||||
|
||||
// Check if there are changes from original
|
||||
const hasChanges = originalConfig && Object.keys(newConfig).some(k => {
|
||||
const configKey = k as keyof CameraConfig
|
||||
return newConfig[configKey] !== originalConfig[configKey]
|
||||
})
|
||||
setHasChanges(!!hasChanges)
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!config || !originalConfig) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
// Build update object with only changed values
|
||||
const updates: CameraConfigUpdate = {}
|
||||
const configKeys: (keyof CameraConfigUpdate)[] = [
|
||||
'exposure_ms', 'gain', 'target_fps', 'sharpness', 'contrast', 'saturation',
|
||||
'gamma', 'noise_filter_enabled', 'denoise_3d_enabled', 'auto_white_balance',
|
||||
'color_temperature_preset', 'anti_flicker_enabled', 'light_frequency',
|
||||
'hdr_enabled', 'hdr_gain_mode', 'auto_record_on_machine_start',
|
||||
'auto_start_recording_enabled', 'auto_recording_max_retries', 'auto_recording_retry_delay_seconds'
|
||||
]
|
||||
|
||||
configKeys.forEach(key => {
|
||||
if (config[key] !== originalConfig[key]) {
|
||||
updates[key] = config[key] as any
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
onSuccess?.('No changes to save')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await visionApi.updateCameraConfig(cameraName, updates)
|
||||
|
||||
if (result.success) {
|
||||
setOriginalConfig(config)
|
||||
setHasChanges(false)
|
||||
onSuccess?.(`Configuration updated: ${result.updated_settings.join(', ')}`)
|
||||
} else {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to save configuration'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyConfig = async () => {
|
||||
try {
|
||||
setApplying(true)
|
||||
setError(null)
|
||||
|
||||
const result = await visionApi.applyCameraConfig(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.('Configuration applied successfully. Camera restarted.')
|
||||
} else {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to apply configuration'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetChanges = () => {
|
||||
if (originalConfig) {
|
||||
setConfig(originalConfig)
|
||||
setHasChanges(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Camera Configuration - {cameraName}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4 overflow-y-auto max-h-[calc(90vh-140px)]">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
<span className="ml-2 text-gray-600">Loading configuration...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && !loading && (
|
||||
<div className="space-y-6">
|
||||
{/* Basic Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Basic Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Exposure (ms): {config.exposure_ms}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={config.exposure_ms}
|
||||
onChange={(e) => updateSetting('exposure_ms', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0.1ms</span>
|
||||
<span>10ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Gain: {config.gain}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={config.gain}
|
||||
onChange={(e) => updateSetting('gain', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Target FPS: {config.target_fps} {config.target_fps === 0 ? '(Maximum)' : ''}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="30"
|
||||
step="1"
|
||||
value={config.target_fps}
|
||||
onChange={(e) => updateSetting('target_fps', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0 (Max)</span>
|
||||
<span>30</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Quality Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Image Quality</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sharpness: {config.sharpness}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.sharpness}
|
||||
onChange={(e) => updateSetting('sharpness', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contrast: {config.contrast}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.contrast}
|
||||
onChange={(e) => updateSetting('contrast', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Saturation: {config.saturation}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.saturation}
|
||||
onChange={(e) => updateSetting('saturation', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Gamma: {config.gamma}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="300"
|
||||
value={config.gamma}
|
||||
onChange={(e) => updateSetting('gamma', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>300</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Color Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.auto_white_balance}
|
||||
onChange={(e) => updateSetting('auto_white_balance', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Auto White Balance</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color Temperature Preset: {config.color_temperature_preset} {config.color_temperature_preset === 0 ? '(Auto)' : ''}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
value={config.color_temperature_preset}
|
||||
onChange={(e) => updateSetting('color_temperature_preset', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0 (Auto)</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Advanced Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.anti_flicker_enabled}
|
||||
onChange={(e) => updateSetting('anti_flicker_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Anti-flicker Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Light Frequency: {config.light_frequency === 0 ? '50Hz' : '60Hz'}
|
||||
</label>
|
||||
<select
|
||||
value={config.light_frequency}
|
||||
onChange={(e) => updateSetting('light_frequency', parseInt(e.target.value))}
|
||||
className="w-full border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value={0}>50Hz</option>
|
||||
<option value={1}>60Hz</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.noise_filter_enabled}
|
||||
onChange={(e) => updateSetting('noise_filter_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Noise Filter Enabled</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Requires restart to apply</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.denoise_3d_enabled}
|
||||
onChange={(e) => updateSetting('denoise_3d_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">3D Denoise Enabled</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Requires restart to apply</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HDR Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">HDR Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hdr_enabled}
|
||||
onChange={(e) => updateSetting('hdr_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">HDR Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
HDR Gain Mode: {config.hdr_gain_mode}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3"
|
||||
value={config.hdr_gain_mode}
|
||||
onChange={(e) => updateSetting('hdr_gain_mode', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
disabled={!config.hdr_enabled}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-Recording Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Auto-Recording Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.auto_record_on_machine_start}
|
||||
onChange={(e) => updateSetting('auto_record_on_machine_start', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Auto Record on Machine Start</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Start recording when MQTT machine state changes to ON</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.auto_start_recording_enabled ?? false}
|
||||
onChange={(e) => updateSetting('auto_start_recording_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Enhanced Auto Recording</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Advanced auto-recording with retry logic</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Retries: {config.auto_recording_max_retries ?? 3}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={config.auto_recording_max_retries ?? 3}
|
||||
onChange={(e) => updateSetting('auto_recording_max_retries', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
disabled={!config.auto_start_recording_enabled}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>1</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Retry Delay: {config.auto_recording_retry_delay_seconds ?? 5}s
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="30"
|
||||
value={config.auto_recording_retry_delay_seconds ?? 5}
|
||||
onChange={(e) => updateSetting('auto_recording_retry_delay_seconds', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
disabled={!config.auto_start_recording_enabled}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>1s</span>
|
||||
<span>30s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Information */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">Configuration Notes</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Real-time settings (exposure, gain, image quality) apply immediately</li>
|
||||
<li>Noise reduction settings require camera restart to take effect</li>
|
||||
<li>Use "Apply & Restart" to apply settings that require restart</li>
|
||||
<li>HDR mode may impact performance when enabled</li>
|
||||
<li>Auto-recording monitors MQTT machine state changes for automatic recording</li>
|
||||
<li>Enhanced auto-recording provides retry logic for failed recording attempts</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{config && !loading && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-orange-600 font-medium">
|
||||
You have unsaved changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={resetChanges}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
disabled={!hasChanges || saving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button
|
||||
onClick={applyConfig}
|
||||
disabled={applying}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{applying ? 'Applying...' : 'Apply & Restart'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
src/components/CameraPreviewModal.tsx
Normal file
194
src/components/CameraPreviewModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { visionApi } from '../lib/visionApi'
|
||||
|
||||
interface CameraPreviewModalProps {
|
||||
cameraName: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function CameraPreviewModal({ cameraName, isOpen, onClose, onError }: CameraPreviewModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const streamUrlRef = useRef<string | null>(null)
|
||||
|
||||
// Start streaming when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && cameraName) {
|
||||
startStreaming()
|
||||
}
|
||||
}, [isOpen, cameraName])
|
||||
|
||||
// Stop streaming when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen && streaming) {
|
||||
stopStreaming()
|
||||
}
|
||||
}, [isOpen, streaming])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streaming) {
|
||||
stopStreaming()
|
||||
}
|
||||
}
|
||||
}, [streaming])
|
||||
|
||||
const startStreaming = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await visionApi.startStream(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
setStreaming(true)
|
||||
const streamUrl = visionApi.getStreamUrl(cameraName)
|
||||
streamUrlRef.current = streamUrl
|
||||
|
||||
// Add timestamp to prevent caching
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = `${streamUrl}?t=${Date.now()}`
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to start stream'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stopStreaming = async () => {
|
||||
try {
|
||||
if (streaming) {
|
||||
await visionApi.stopStream(cameraName)
|
||||
setStreaming(false)
|
||||
streamUrlRef.current = null
|
||||
|
||||
// Clear the image source
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error stopping stream:', err)
|
||||
// Don't show error to user for stop stream failures
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopStreaming()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
setError('Failed to load camera stream')
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-11/12 max-w-4xl shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Camera Preview: {cameraName}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-600 focus:outline-none"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-64 bg-gray-100 rounded-lg">
|
||||
<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">Starting camera stream...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<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">Stream Error</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={startStreaming}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streaming && !loading && !error && (
|
||||
<div className="bg-black rounded-lg overflow-hidden">
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt={`Live stream from ${cameraName}`}
|
||||
className="w-full h-auto max-h-96 object-contain"
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{streaming && (
|
||||
<div className="flex items-center text-green-600">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></div>
|
||||
<span className="text-sm font-medium">Live Stream Active</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
formatDuration,
|
||||
formatUptime
|
||||
} from '../lib/visionApi'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { CameraConfigModal } from './CameraConfigModal'
|
||||
|
||||
// Memoized components to prevent unnecessary re-renders
|
||||
const SystemOverview = memo(({ systemStatus }: { systemStatus: SystemStatus }) => (
|
||||
@@ -160,130 +162,207 @@ const StorageOverview = memo(({ storageStats }: { storageStats: StorageStats })
|
||||
</div>
|
||||
))
|
||||
|
||||
const CamerasStatus = memo(({ systemStatus }: { systemStatus: SystemStatus }) => (
|
||||
<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
|
||||
const CamerasStatus = memo(({
|
||||
systemStatus,
|
||||
onConfigureCamera,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onPreviewCamera
|
||||
}: {
|
||||
systemStatus: SystemStatus,
|
||||
onConfigureCamera: (cameraName: string) => void,
|
||||
onStartRecording: (cameraName: string) => Promise<void>,
|
||||
onStopRecording: (cameraName: string) => Promise<void>,
|
||||
onPreviewCamera: (cameraName: string) => void
|
||||
}) => {
|
||||
const { isAdmin } = useAuth()
|
||||
|
||||
// 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 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
|
||||
|
||||
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>
|
||||
// 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'
|
||||
|
||||
<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'
|
||||
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'
|
||||
}`}>
|
||||
{statusText.charAt(0).toUpperCase() + statusText.slice(1)}
|
||||
</span>
|
||||
{isConnected ? 'Connected' : hasError ? 'Error' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{camera.is_recording && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{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}
|
||||
{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>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onPreviewCamera(cameraName)}
|
||||
disabled={!isConnected}
|
||||
className={`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>
|
||||
</div>
|
||||
|
||||
{/* Admin Configuration Button */}
|
||||
{isAdmin() && (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
})
|
||||
|
||||
const RecentRecordings = memo(({ recordings, systemStatus }: { recordings: Record<string, RecordingInfo>, systemStatus: SystemStatus | null }) => (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
@@ -374,6 +453,11 @@ export function VisionSystem() {
|
||||
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)
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const clearAutoRefresh = useCallback(() => {
|
||||
@@ -486,6 +570,22 @@ export function VisionSystem() {
|
||||
}
|
||||
}, [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)
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string, isRecording: boolean = false) => {
|
||||
// If camera is recording, always show red regardless of status
|
||||
if (isRecording) {
|
||||
@@ -641,7 +741,7 @@ export function VisionSystem() {
|
||||
|
||||
|
||||
{/* Cameras Status */}
|
||||
{systemStatus && <CamerasStatus systemStatus={systemStatus} />}
|
||||
{systemStatus && <CamerasStatus systemStatus={systemStatus} onConfigureCamera={handleConfigureCamera} />}
|
||||
|
||||
{/* Machines Status */}
|
||||
{systemStatus && Object.keys(systemStatus.machines).length > 0 && (
|
||||
@@ -697,6 +797,58 @@ export function VisionSystem() {
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
48
src/hooks/useAuth.ts
Normal file
48
src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { userManagement, type User } from '../lib/supabase'
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const currentUser = await userManagement.getCurrentUser()
|
||||
setUser(currentUser)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load user')
|
||||
setUser(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = () => {
|
||||
return user?.roles.includes('admin') ?? false
|
||||
}
|
||||
|
||||
const hasRole = (role: string) => {
|
||||
return user?.roles.includes(role as any) ?? false
|
||||
}
|
||||
|
||||
const hasAnyRole = (roles: string[]) => {
|
||||
return roles.some(role => user?.roles.includes(role as any)) ?? false
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
isAdmin,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
refreshUser: loadUser
|
||||
}
|
||||
}
|
||||
81
src/hooks/useAutoRecording.ts
Normal file
81
src/hooks/useAutoRecording.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* React hook for managing auto-recording functionality
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { autoRecordingManager, type AutoRecordingState } from '../lib/autoRecordingManager'
|
||||
|
||||
export interface UseAutoRecordingResult {
|
||||
isRunning: boolean
|
||||
states: AutoRecordingState[]
|
||||
error: string | null
|
||||
start: () => Promise<void>
|
||||
stop: () => void
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useAutoRecording(): UseAutoRecordingResult {
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [states, setStates] = useState<AutoRecordingState[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Update states periodically
|
||||
useEffect(() => {
|
||||
if (!isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setStates(autoRecordingManager.getStates())
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isRunning])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
await autoRecordingManager.start()
|
||||
setIsRunning(true)
|
||||
setStates(autoRecordingManager.getStates())
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to start auto-recording'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to start auto-recording:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
try {
|
||||
autoRecordingManager.stop()
|
||||
setIsRunning(false)
|
||||
setStates([])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to stop auto-recording'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to stop auto-recording:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
await autoRecordingManager.refreshConfigurations()
|
||||
setStates(autoRecordingManager.getStates())
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to refresh configurations'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to refresh auto-recording configurations:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
states,
|
||||
error,
|
||||
start,
|
||||
stop,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
286
src/lib/autoRecordingManager.ts
Normal file
286
src/lib/autoRecordingManager.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Auto-Recording Manager
|
||||
*
|
||||
* This module handles automatic recording start/stop based on MQTT machine state changes.
|
||||
* It monitors MQTT events and triggers camera recording when machines turn on/off.
|
||||
*/
|
||||
|
||||
import { visionApi, type MqttEvent, type CameraConfig } from './visionApi'
|
||||
|
||||
export interface AutoRecordingState {
|
||||
cameraName: string
|
||||
machineState: 'on' | 'off'
|
||||
isRecording: boolean
|
||||
autoRecordEnabled: boolean
|
||||
lastStateChange: Date
|
||||
}
|
||||
|
||||
export class AutoRecordingManager {
|
||||
private cameras: Map<string, AutoRecordingState> = new Map()
|
||||
private mqttPollingInterval: NodeJS.Timeout | null = null
|
||||
private lastProcessedEventNumber = 0
|
||||
private isRunning = false
|
||||
|
||||
constructor(private pollingIntervalMs: number = 2000) {}
|
||||
|
||||
/**
|
||||
* Start the auto-recording manager
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
console.warn('Auto-recording manager is already running')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Starting auto-recording manager...')
|
||||
this.isRunning = true
|
||||
|
||||
// Initialize camera configurations
|
||||
await this.initializeCameras()
|
||||
|
||||
// Start polling for MQTT events
|
||||
this.startMqttPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the auto-recording manager
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Stopping auto-recording manager...')
|
||||
this.isRunning = false
|
||||
|
||||
if (this.mqttPollingInterval) {
|
||||
clearInterval(this.mqttPollingInterval)
|
||||
this.mqttPollingInterval = null
|
||||
}
|
||||
|
||||
this.cameras.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize camera configurations and states
|
||||
*/
|
||||
private async initializeCameras(): Promise<void> {
|
||||
try {
|
||||
const cameras = await visionApi.getCameras()
|
||||
|
||||
for (const [cameraName, cameraStatus] of Object.entries(cameras)) {
|
||||
try {
|
||||
const config = await visionApi.getCameraConfig(cameraName)
|
||||
|
||||
this.cameras.set(cameraName, {
|
||||
cameraName,
|
||||
machineState: 'off', // Default to off
|
||||
isRecording: cameraStatus.is_recording,
|
||||
autoRecordEnabled: config.auto_record_on_machine_start,
|
||||
lastStateChange: new Date()
|
||||
})
|
||||
|
||||
console.log(`Initialized camera ${cameraName}: auto-record=${config.auto_record_on_machine_start}, machine=${config.machine_topic}`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize camera ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize cameras:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for MQTT events
|
||||
*/
|
||||
private startMqttPolling(): void {
|
||||
this.mqttPollingInterval = setInterval(async () => {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.processMqttEvents()
|
||||
} catch (error) {
|
||||
console.error('Error processing MQTT events:', error)
|
||||
}
|
||||
}, this.pollingIntervalMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process new MQTT events and trigger recording actions
|
||||
*/
|
||||
private async processMqttEvents(): Promise<void> {
|
||||
try {
|
||||
const mqttResponse = await visionApi.getMqttEvents(50) // Get recent events
|
||||
|
||||
// Filter for new events we haven't processed yet
|
||||
const newEvents = mqttResponse.events.filter(
|
||||
event => event.message_number > this.lastProcessedEventNumber
|
||||
)
|
||||
|
||||
if (newEvents.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update last processed event number
|
||||
this.lastProcessedEventNumber = Math.max(
|
||||
...newEvents.map(event => event.message_number)
|
||||
)
|
||||
|
||||
// Process each new event
|
||||
for (const event of newEvents) {
|
||||
await this.handleMqttEvent(event)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MQTT events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single MQTT event and trigger recording if needed
|
||||
*/
|
||||
private async handleMqttEvent(event: MqttEvent): Promise<void> {
|
||||
const { machine_name, normalized_state } = event
|
||||
|
||||
// Find cameras that are configured for this machine
|
||||
const affectedCameras = await this.getCamerasForMachine(machine_name)
|
||||
|
||||
for (const cameraName of affectedCameras) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
|
||||
if (!cameraState || !cameraState.autoRecordEnabled) {
|
||||
continue
|
||||
}
|
||||
|
||||
const newMachineState = normalized_state as 'on' | 'off'
|
||||
|
||||
// Skip if state hasn't changed
|
||||
if (cameraState.machineState === newMachineState) {
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`Machine ${machine_name} changed from ${cameraState.machineState} to ${newMachineState} - Camera: ${cameraName}`)
|
||||
|
||||
// Update camera state
|
||||
cameraState.machineState = newMachineState
|
||||
cameraState.lastStateChange = new Date()
|
||||
|
||||
// Trigger recording action
|
||||
if (newMachineState === 'on' && !cameraState.isRecording) {
|
||||
await this.startAutoRecording(cameraName, machine_name)
|
||||
} else if (newMachineState === 'off' && cameraState.isRecording) {
|
||||
await this.stopAutoRecording(cameraName, machine_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cameras that are configured for a specific machine
|
||||
*/
|
||||
private async getCamerasForMachine(machineName: string): Promise<string[]> {
|
||||
const cameras: string[] = []
|
||||
|
||||
// Define the correct machine-to-camera mapping
|
||||
const machineToCamera: Record<string, string> = {
|
||||
'blower_separator': 'camera1', // camera1 is for blower separator
|
||||
'vibratory_conveyor': 'camera2' // camera2 is for conveyor
|
||||
}
|
||||
|
||||
const expectedCamera = machineToCamera[machineName]
|
||||
if (!expectedCamera) {
|
||||
console.warn(`No camera mapping found for machine: ${machineName}`)
|
||||
return cameras
|
||||
}
|
||||
|
||||
try {
|
||||
const allCameras = await visionApi.getCameras()
|
||||
|
||||
// Check if the expected camera exists and has auto-recording enabled
|
||||
if (allCameras[expectedCamera]) {
|
||||
try {
|
||||
const config = await visionApi.getCameraConfig(expectedCamera)
|
||||
|
||||
if (config.auto_record_on_machine_start) {
|
||||
cameras.push(expectedCamera)
|
||||
console.log(`Found camera ${expectedCamera} configured for machine ${machineName}`)
|
||||
} else {
|
||||
console.log(`Camera ${expectedCamera} exists but auto-recording is disabled`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get config for camera ${expectedCamera}:`, error)
|
||||
}
|
||||
} else {
|
||||
console.warn(`Expected camera ${expectedCamera} not found for machine ${machineName}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get cameras for machine:', error)
|
||||
}
|
||||
|
||||
return cameras
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-recording for a camera
|
||||
*/
|
||||
private async startAutoRecording(cameraName: string, machineName: string): Promise<void> {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `auto_${machineName}_${timestamp}.avi`
|
||||
|
||||
const result = await visionApi.startRecording(cameraName, { filename })
|
||||
|
||||
if (result.success) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
if (cameraState) {
|
||||
cameraState.isRecording = true
|
||||
}
|
||||
|
||||
console.log(`✅ Auto-recording started for ${cameraName}: ${result.filename}`)
|
||||
} else {
|
||||
console.error(`❌ Failed to start auto-recording for ${cameraName}:`, result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error starting auto-recording for ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-recording for a camera
|
||||
*/
|
||||
private async stopAutoRecording(cameraName: string, machineName: string): Promise<void> {
|
||||
try {
|
||||
const result = await visionApi.stopRecording(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
if (cameraState) {
|
||||
cameraState.isRecording = false
|
||||
}
|
||||
|
||||
console.log(`⏹️ Auto-recording stopped for ${cameraName} (${result.duration_seconds}s)`)
|
||||
} else {
|
||||
console.error(`❌ Failed to stop auto-recording for ${cameraName}:`, result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error stopping auto-recording for ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current auto-recording states for all cameras
|
||||
*/
|
||||
getStates(): AutoRecordingState[] {
|
||||
return Array.from(this.cameras.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh camera configurations (call when configs are updated)
|
||||
*/
|
||||
async refreshConfigurations(): Promise<void> {
|
||||
await this.initializeCameras()
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const autoRecordingManager = new AutoRecordingManager()
|
||||
@@ -40,6 +40,12 @@ export interface CameraStatus {
|
||||
recording_start_time?: string | null
|
||||
last_frame_time?: string
|
||||
frame_rate?: number
|
||||
// NEW AUTO-RECORDING FIELDS
|
||||
auto_recording_enabled: boolean
|
||||
auto_recording_active: boolean
|
||||
auto_recording_failure_count: number
|
||||
auto_recording_last_attempt?: string
|
||||
auto_recording_last_error?: string
|
||||
}
|
||||
|
||||
export interface RecordingInfo {
|
||||
@@ -96,6 +102,16 @@ export interface StopRecordingResponse {
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export interface StreamStartResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface StreamStopResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CameraTestResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
@@ -111,6 +127,83 @@ export interface CameraRecoveryResponse {
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// Auto-Recording Response Types
|
||||
export interface AutoRecordingConfigResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
camera_name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AutoRecordingStatusResponse {
|
||||
running: boolean
|
||||
auto_recording_enabled: boolean
|
||||
retry_queue: Record<string, any>
|
||||
enabled_cameras: string[]
|
||||
}
|
||||
|
||||
// Camera Configuration Types
|
||||
export interface CameraConfig {
|
||||
name: string
|
||||
machine_topic: string
|
||||
storage_path: string
|
||||
enabled: boolean
|
||||
auto_record_on_machine_start: boolean
|
||||
// NEW AUTO-RECORDING CONFIG FIELDS (optional for backward compatibility)
|
||||
auto_start_recording_enabled?: boolean
|
||||
auto_recording_max_retries?: number
|
||||
auto_recording_retry_delay_seconds?: number
|
||||
exposure_ms: number
|
||||
gain: number
|
||||
target_fps: number
|
||||
sharpness: number
|
||||
contrast: number
|
||||
saturation: number
|
||||
gamma: number
|
||||
noise_filter_enabled: boolean
|
||||
denoise_3d_enabled: boolean
|
||||
auto_white_balance: boolean
|
||||
color_temperature_preset: number
|
||||
anti_flicker_enabled: boolean
|
||||
light_frequency: number
|
||||
bit_depth: number
|
||||
hdr_enabled: boolean
|
||||
hdr_gain_mode: number
|
||||
}
|
||||
|
||||
export interface CameraConfigUpdate {
|
||||
auto_record_on_machine_start?: boolean
|
||||
auto_start_recording_enabled?: boolean
|
||||
auto_recording_max_retries?: number
|
||||
auto_recording_retry_delay_seconds?: number
|
||||
exposure_ms?: number
|
||||
gain?: number
|
||||
target_fps?: number
|
||||
sharpness?: number
|
||||
contrast?: number
|
||||
saturation?: number
|
||||
gamma?: number
|
||||
noise_filter_enabled?: boolean
|
||||
denoise_3d_enabled?: boolean
|
||||
auto_white_balance?: boolean
|
||||
color_temperature_preset?: number
|
||||
anti_flicker_enabled?: boolean
|
||||
light_frequency?: number
|
||||
hdr_enabled?: boolean
|
||||
hdr_gain_mode?: number
|
||||
}
|
||||
|
||||
export interface CameraConfigUpdateResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
updated_settings: string[]
|
||||
}
|
||||
|
||||
export interface CameraConfigApplyResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface MqttMessage {
|
||||
timestamp: string
|
||||
topic: string
|
||||
@@ -239,6 +332,23 @@ class VisionApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
// Streaming control
|
||||
async startStream(cameraName: string): Promise<StreamStartResponse> {
|
||||
return this.request(`/cameras/${cameraName}/start-stream`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async stopStream(cameraName: string): Promise<StreamStopResponse> {
|
||||
return this.request(`/cameras/${cameraName}/stop-stream`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
getStreamUrl(cameraName: string): string {
|
||||
return `${this.baseUrl}/cameras/${cameraName}/stream`
|
||||
}
|
||||
|
||||
// Camera diagnostics
|
||||
async testCameraConnection(cameraName: string): Promise<CameraTestResponse> {
|
||||
return this.request(`/cameras/${cameraName}/test-connection`, {
|
||||
@@ -276,6 +386,84 @@ class VisionApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
// Camera configuration
|
||||
async getCameraConfig(cameraName: string): Promise<CameraConfig> {
|
||||
try {
|
||||
const config = await this.request(`/cameras/${cameraName}/config`) as any
|
||||
|
||||
// Ensure auto-recording fields have default values if missing
|
||||
return {
|
||||
...config,
|
||||
auto_start_recording_enabled: config.auto_start_recording_enabled ?? false,
|
||||
auto_recording_max_retries: config.auto_recording_max_retries ?? 3,
|
||||
auto_recording_retry_delay_seconds: config.auto_recording_retry_delay_seconds ?? 5
|
||||
}
|
||||
} catch (error: any) {
|
||||
// If the error is related to missing auto-recording fields, try to handle it gracefully
|
||||
if (error.message?.includes('auto_start_recording_enabled') ||
|
||||
error.message?.includes('auto_recording_max_retries') ||
|
||||
error.message?.includes('auto_recording_retry_delay_seconds')) {
|
||||
|
||||
// Try to get the raw camera data and add default auto-recording fields
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/cameras/${cameraName}/config`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const rawConfig = await response.json()
|
||||
|
||||
// Add missing auto-recording fields with defaults
|
||||
return {
|
||||
...rawConfig,
|
||||
auto_start_recording_enabled: false,
|
||||
auto_recording_max_retries: 3,
|
||||
auto_recording_retry_delay_seconds: 5
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
throw new Error(`Failed to load camera configuration: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async updateCameraConfig(cameraName: string, config: CameraConfigUpdate): Promise<CameraConfigUpdateResponse> {
|
||||
return this.request(`/cameras/${cameraName}/config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
}
|
||||
|
||||
async applyCameraConfig(cameraName: string): Promise<CameraConfigApplyResponse> {
|
||||
return this.request(`/cameras/${cameraName}/apply-config`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-Recording endpoints
|
||||
async enableAutoRecording(cameraName: string): Promise<AutoRecordingConfigResponse> {
|
||||
return this.request(`/cameras/${cameraName}/auto-recording/enable`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async disableAutoRecording(cameraName: string): Promise<AutoRecordingConfigResponse> {
|
||||
return this.request(`/cameras/${cameraName}/auto-recording/disable`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async getAutoRecordingStatus(): Promise<AutoRecordingStatusResponse> {
|
||||
return this.request('/auto-recording/status')
|
||||
}
|
||||
|
||||
// Recording sessions
|
||||
async getRecordings(): Promise<Record<string, RecordingInfo>> {
|
||||
return this.request('/recordings')
|
||||
|
||||
Reference in New Issue
Block a user