Remove deprecated files and scripts to streamline the codebase
- Deleted unused API test files, RTSP diagnostic scripts, and development utility scripts to reduce clutter. - Removed outdated database schema and modularization proposal documents to maintain focus on current architecture. - Cleaned up configuration files and logging scripts that are no longer in use, enhancing project maintainability.
This commit is contained in:
466
vision-system-remote/src/App.tsx
Normal file
466
vision-system-remote/src/App.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useWebSocket } from './hooks/useWebSocket'
|
||||
import { visionApi, type SystemStatus, type CameraStatus, type RecordingInfo } from './services/api'
|
||||
import { SystemHealthWidget } from './widgets/SystemHealthWidget'
|
||||
import { MqttStatusWidget } from './widgets/MqttStatusWidget'
|
||||
import { RecordingsCountWidget } from './widgets/RecordingsCountWidget'
|
||||
import { CameraCountWidget } from './widgets/CameraCountWidget'
|
||||
import { CameraCard } from './components/CameraCard'
|
||||
import { CameraPreviewModal } from './components/CameraPreviewModal'
|
||||
import { CameraConfigModal } from './components/CameraConfigModal'
|
||||
|
||||
// Get WebSocket URL from environment or construct it
|
||||
const getWebSocketUrl = () => {
|
||||
const apiUrl = import.meta.env.VITE_VISION_API_URL || '/api'
|
||||
|
||||
// If it's a relative path, use relative WebSocket URL
|
||||
if (apiUrl.startsWith('/')) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${window.location.host}${apiUrl.replace(/\/api$/, '')}/ws`
|
||||
}
|
||||
|
||||
// Convert http(s):// to ws(s)://
|
||||
const wsUrl = apiUrl.replace(/^http/, 'ws')
|
||||
return `${wsUrl.replace(/\/api$/, '')}/ws`
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
|
||||
const [cameras, setCameras] = useState<Record<string, CameraStatus>>({})
|
||||
const [recordings, setRecordings] = useState<Record<string, RecordingInfo>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null)
|
||||
const [notification, setNotification] = useState<{ type: 'success' | 'error', message: string } | null>(null)
|
||||
|
||||
// Modal states
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
const [previewCamera, setPreviewCamera] = useState<string | null>(null)
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false)
|
||||
const [selectedCamera, setSelectedCamera] = useState<string | null>(null)
|
||||
|
||||
// WebSocket connection
|
||||
const { isConnected, subscribe } = useWebSocket(getWebSocketUrl())
|
||||
|
||||
// Fetch initial data
|
||||
const fetchInitialData = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const [status, camerasData, recordingsData] = await Promise.all([
|
||||
visionApi.getSystemStatus(),
|
||||
visionApi.getCameras(),
|
||||
visionApi.getRecordings(),
|
||||
])
|
||||
|
||||
setSystemStatus(status)
|
||||
setCameras(camerasData)
|
||||
setRecordings(recordingsData)
|
||||
setLastUpdate(new Date())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch data')
|
||||
console.error('Failed to fetch initial data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Set up WebSocket subscriptions for real-time updates
|
||||
useEffect(() => {
|
||||
const unsubscribeFunctions: Array<() => void> = []
|
||||
|
||||
// Subscribe to camera status changes
|
||||
unsubscribeFunctions.push(
|
||||
subscribe('camera_status_changed', (event) => {
|
||||
const { camera_name, status, is_recording } = event.data
|
||||
setCameras((prev) => ({
|
||||
...prev,
|
||||
[camera_name]: {
|
||||
...prev[camera_name],
|
||||
status,
|
||||
is_recording,
|
||||
last_checked: new Date().toISOString(),
|
||||
},
|
||||
}))
|
||||
setLastUpdate(new Date())
|
||||
})
|
||||
)
|
||||
|
||||
// Subscribe to recording started events
|
||||
unsubscribeFunctions.push(
|
||||
subscribe('recording_started', (event) => {
|
||||
const { camera_name } = event.data
|
||||
setCameras((prev) => ({
|
||||
...prev,
|
||||
[camera_name]: {
|
||||
...prev[camera_name],
|
||||
is_recording: true,
|
||||
},
|
||||
}))
|
||||
|
||||
// Refresh recordings to get accurate count
|
||||
visionApi.getRecordings().then(setRecordings).catch(console.error)
|
||||
|
||||
// Refresh system status to update counts
|
||||
visionApi.getSystemStatus().then(setSystemStatus).catch(console.error)
|
||||
|
||||
setLastUpdate(new Date())
|
||||
})
|
||||
)
|
||||
|
||||
// Subscribe to recording stopped events
|
||||
unsubscribeFunctions.push(
|
||||
subscribe('recording_stopped', (event) => {
|
||||
const { camera_name } = event.data
|
||||
setCameras((prev) => ({
|
||||
...prev,
|
||||
[camera_name]: {
|
||||
...prev[camera_name],
|
||||
is_recording: false,
|
||||
},
|
||||
}))
|
||||
|
||||
// Refresh recordings and system status
|
||||
Promise.all([
|
||||
visionApi.getRecordings(),
|
||||
visionApi.getSystemStatus(),
|
||||
]).then(([recordingsData, statusData]) => {
|
||||
setRecordings(recordingsData)
|
||||
setSystemStatus(statusData)
|
||||
}).catch(console.error)
|
||||
|
||||
setLastUpdate(new Date())
|
||||
})
|
||||
)
|
||||
|
||||
// Subscribe to system status changes
|
||||
unsubscribeFunctions.push(
|
||||
subscribe('system_status_changed', () => {
|
||||
visionApi.getSystemStatus().then(setSystemStatus).catch(console.error)
|
||||
setLastUpdate(new Date())
|
||||
})
|
||||
)
|
||||
|
||||
// Subscribe to MQTT status changes
|
||||
unsubscribeFunctions.push(
|
||||
subscribe('mqtt_status_changed', () => {
|
||||
visionApi.getSystemStatus().then(setSystemStatus).catch(console.error)
|
||||
setLastUpdate(new Date())
|
||||
})
|
||||
)
|
||||
|
||||
return () => {
|
||||
unsubscribeFunctions.forEach((unsub) => unsub())
|
||||
}
|
||||
}, [subscribe])
|
||||
|
||||
// Fetch initial data on mount
|
||||
useEffect(() => {
|
||||
fetchInitialData()
|
||||
}, [fetchInitialData])
|
||||
|
||||
// Camera action handlers
|
||||
const handleStartRecording = useCallback(async (cameraName: string) => {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `manual_${cameraName}_${timestamp}.mp4`
|
||||
const result = await visionApi.startRecording(cameraName, filename)
|
||||
|
||||
if (result.success) {
|
||||
setNotification({ type: 'success', message: `Recording started: ${result.filename}` })
|
||||
|
||||
// Immediately update state optimistically (UI updates instantly)
|
||||
setCameras((prev) => ({
|
||||
...prev,
|
||||
[cameraName]: {
|
||||
...prev[cameraName],
|
||||
is_recording: true,
|
||||
current_recording_file: result.filename,
|
||||
},
|
||||
}))
|
||||
|
||||
// Refresh camera status from API as backup (in case WebSocket is delayed)
|
||||
setTimeout(() => {
|
||||
visionApi.getCameras().then(setCameras).catch(console.error)
|
||||
}, 500)
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed: ${result.message}` })
|
||||
}
|
||||
} catch (err) {
|
||||
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Unknown error' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStopRecording = useCallback(async (cameraName: string) => {
|
||||
try {
|
||||
const result = await visionApi.stopRecording(cameraName)
|
||||
if (result.success) {
|
||||
setNotification({ type: 'success', message: 'Recording stopped' })
|
||||
|
||||
// Immediately update state optimistically (UI updates instantly)
|
||||
setCameras((prev) => ({
|
||||
...prev,
|
||||
[cameraName]: {
|
||||
...prev[cameraName],
|
||||
is_recording: false,
|
||||
current_recording_file: null,
|
||||
},
|
||||
}))
|
||||
|
||||
// Refresh camera status from API as backup (in case WebSocket is delayed)
|
||||
setTimeout(() => {
|
||||
visionApi.getCameras().then(setCameras).catch(console.error)
|
||||
}, 500)
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed: ${result.message}` })
|
||||
}
|
||||
} catch (err) {
|
||||
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Unknown error' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePreviewModal = useCallback((cameraName: string) => {
|
||||
setPreviewCamera(cameraName)
|
||||
setPreviewModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handlePreviewNewWindow = useCallback((cameraName: string) => {
|
||||
// Open camera stream in new window/tab
|
||||
const streamUrl = visionApi.getStreamUrl(cameraName)
|
||||
window.open(streamUrl, '_blank')
|
||||
}, [])
|
||||
|
||||
const handleConfigure = useCallback((cameraName: string) => {
|
||||
setSelectedCamera(cameraName)
|
||||
setConfigModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleRestart = useCallback(async (cameraName: string) => {
|
||||
try {
|
||||
setNotification({ type: 'success', message: `Restarting camera ${cameraName}...` })
|
||||
const result = await visionApi.reinitializeCamera(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
setNotification({ type: 'success', message: `Camera ${cameraName} restarted successfully` })
|
||||
// Refresh camera status
|
||||
setTimeout(() => {
|
||||
visionApi.getCameras().then(setCameras).catch(console.error)
|
||||
visionApi.getSystemStatus().then(setSystemStatus).catch(console.error)
|
||||
}, 2000) // Wait 2 seconds for camera to reinitialize
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed: ${result.message}` })
|
||||
}
|
||||
} catch (err) {
|
||||
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Unknown error' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStopStreaming = useCallback(async (cameraName: string) => {
|
||||
try {
|
||||
const result = await visionApi.stopStream(cameraName)
|
||||
if (result.success) {
|
||||
setNotification({ type: 'success', message: 'Streaming stopped' })
|
||||
// Refresh camera status
|
||||
visionApi.getCameras().then(setCameras).catch(console.error)
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed: ${result.message}` })
|
||||
}
|
||||
} catch (err) {
|
||||
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Unknown error' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-hide notifications
|
||||
useEffect(() => {
|
||||
if (notification) {
|
||||
const timer = setTimeout(() => setNotification(null), 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [notification])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto" />
|
||||
<p className="mt-4 text-gray-600">Loading vision system...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error loading vision system</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={fetchInitialData}
|
||||
className="bg-red-100 px-3 py-2 rounded-md text-sm font-medium text-red-800 hover:bg-red-200"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cameraCount = Object.keys(cameras).length
|
||||
const machineCount = systemStatus ? Object.keys(systemStatus.machines).length : 0
|
||||
const activeRecordings = systemStatus?.active_recordings ?? 0
|
||||
// Fix: Use recordings object length instead of total_recordings (which may be incorrect)
|
||||
const totalRecordings = Object.keys(recordings).length
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Vision System</h1>
|
||||
<p className="mt-2 text-gray-600">Monitor cameras, machines, and recording status</p>
|
||||
{lastUpdate && (
|
||||
<p className="mt-1 text-sm text-gray-500 flex items-center space-x-2">
|
||||
<span>Last updated: {lastUpdate.toLocaleTimeString()}</span>
|
||||
{isConnected ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse" />
|
||||
Live Updates
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Polling Mode
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Widgets */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<SystemHealthWidget systemStatus={systemStatus} />
|
||||
<MqttStatusWidget systemStatus={systemStatus} />
|
||||
<RecordingsCountWidget active={activeRecordings} total={totalRecordings} />
|
||||
<CameraCountWidget cameraCount={cameraCount} machineCount={machineCount} />
|
||||
</div>
|
||||
|
||||
{/* Cameras Grid */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Cameras</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Current status of all cameras in the system
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Object.entries(cameras).map(([cameraName, camera]) => (
|
||||
<CameraCard
|
||||
key={cameraName}
|
||||
cameraName={cameraName}
|
||||
camera={camera}
|
||||
onStartRecording={handleStartRecording}
|
||||
onStopRecording={handleStopRecording}
|
||||
onPreviewModal={handlePreviewModal}
|
||||
onPreviewNewWindow={handlePreviewNewWindow}
|
||||
onStopStreaming={handleStopStreaming}
|
||||
onConfigure={handleConfigure}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification */}
|
||||
{notification && (
|
||||
<div
|
||||
className={`fixed top-4 right-4 z-[999999] p-4 rounded-md shadow-lg ${
|
||||
notification.type === 'success'
|
||||
? 'bg-green-50 border border-green-200 text-green-800'
|
||||
: 'bg-red-50 border border-red-200 text-red-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
{notification.type === 'success' ? (
|
||||
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium">{notification.message}</p>
|
||||
</div>
|
||||
<div className="ml-auto pl-3">
|
||||
<button
|
||||
onClick={() => setNotification(null)}
|
||||
className={`inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2 ${
|
||||
notification.type === 'success'
|
||||
? 'text-green-500 hover:bg-green-100 focus:ring-green-600'
|
||||
: 'text-red-500 hover:bg-red-100 focus:ring-red-600'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Camera Preview Modal */}
|
||||
{previewCamera && (
|
||||
<CameraPreviewModal
|
||||
cameraName={previewCamera}
|
||||
isOpen={previewModalOpen}
|
||||
onClose={() => {
|
||||
setPreviewModalOpen(false)
|
||||
setPreviewCamera(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Camera Configuration Modal */}
|
||||
{selectedCamera && (
|
||||
<CameraConfigModal
|
||||
cameraName={selectedCamera}
|
||||
isOpen={configModalOpen}
|
||||
onClose={() => {
|
||||
setConfigModalOpen(false)
|
||||
setSelectedCamera(null)
|
||||
}}
|
||||
onSuccess={(message) => {
|
||||
setNotification({ type: 'success', message })
|
||||
// Refresh camera status
|
||||
visionApi.getCameras().then(setCameras).catch(console.error)
|
||||
}}
|
||||
onError={(error) => {
|
||||
setNotification({ type: 'error', message: error })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user