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:
Alireza Vaezi
2025-07-29 12:30:59 -04:00
parent 104f6202fb
commit 0d20fe189d
40 changed files with 8142 additions and 127 deletions

View 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 }

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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
View 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
}
}

View 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
}
}

View 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()

View File

@@ -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')