feat(video-streaming): add ApiStatusIndicator, PerformanceDashboard, VideoDebugger, and VideoErrorBoundary components

- Implemented ApiStatusIndicator to monitor video API connection status with health check functionality.
- Created PerformanceDashboard for monitoring video streaming performance metrics in development mode.
- Developed VideoDebugger for diagnosing video streaming issues with direct access to test video URLs.
- Added VideoErrorBoundary to handle errors in video streaming components with user-friendly messages and recovery options.
- Introduced utility functions for performance monitoring and thumbnail caching to optimize video streaming operations.
- Added comprehensive tests for video streaming API connectivity and functionality.
This commit is contained in:
Alireza Vaezi
2025-08-06 11:46:25 -04:00
parent 228efb0f55
commit 81828f61cf
38 changed files with 3117 additions and 441 deletions

View File

@@ -6,7 +6,7 @@
*/
import React, { useState, useMemo } from 'react';
import { VideoList, VideoModal } from './components';
import { VideoList, VideoModal, ApiStatusIndicator, VideoErrorBoundary, PerformanceDashboard } from './components';
import { type VideoFile, type VideoListFilters, type VideoListSortOptions } from './types';
export const VideoStreamingPage: React.FC = () => {
@@ -50,123 +50,151 @@ export const VideoStreamingPage: React.FC = () => {
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">Video Library</h1>
<p className="mt-2 text-gray-600">
Browse and view recorded videos from your camera system
</p>
</div>
{/* Filters and Controls */}
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-theme-sm">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Camera Filter */}
<VideoErrorBoundary>
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter by Camera
</label>
<select
value={filters.cameraName || 'all'}
onChange={(e) => handleCameraFilterChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="all">All Cameras</option>
{availableCameras.map(camera => (
<option key={camera} value={camera}>
{camera}
</option>
))}
</select>
<h1 className="text-3xl font-bold text-gray-900">Video Library</h1>
<p className="mt-2 text-gray-600">
Browse and view recorded videos from your camera system
</p>
</div>
<div className="flex-shrink-0">
<ApiStatusIndicator showDetails={false} />
</div>
</div>
{/* Sort Options */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort by
</label>
<div className="flex space-x-2">
<select
value={sortOptions.field}
onChange={(e) => handleSortChange(e.target.value as VideoListSortOptions['field'], sortOptions.direction)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="created_at">Date Created</option>
<option value="file_size_bytes">File Size</option>
<option value="camera_name">Camera Name</option>
<option value="filename">Filename</option>
</select>
<button
onClick={() => handleSortChange(sortOptions.field, sortOptions.direction === 'asc' ? 'desc' : 'asc')}
className="px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
title={`Sort ${sortOptions.direction === 'asc' ? 'Descending' : 'Ascending'}`}
>
{sortOptions.direction === 'asc' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
{/* Filters and Controls */}
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-theme-sm">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Camera Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Camera
</label>
<div className="relative">
<select
value={filters.cameraName || 'all'}
onChange={(e) => handleCameraFilterChange(e.target.value)}
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
>
<option value="all">All Cameras</option>
{availableCameras.map(camera => (
<option key={camera} value={camera}>
{camera}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h9m5-4v12m0 0l-4-4m4 4l4-4" />
</svg>
)}
</button>
</div>
</div>
</div>
</div>
{/* Date Range Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date Range
</label>
<div className="flex space-x-2">
{/* Sort Options */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort by
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<select
value={sortOptions.field}
onChange={(e) => handleSortChange(e.target.value as VideoListSortOptions['field'], sortOptions.direction)}
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
>
<option value="created_at">Date Created</option>
<option value="file_size_bytes">File Size</option>
<option value="camera_name">Camera Name</option>
<option value="filename">Filename</option>
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<button
onClick={() => handleSortChange(sortOptions.field, sortOptions.direction === 'asc' ? 'desc' : 'asc')}
className="px-3 py-2.5 border border-gray-300 rounded-lg bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
title={`Sort ${sortOptions.direction === 'asc' ? 'Descending' : 'Ascending'}`}
>
{sortOptions.direction === 'asc' ? (
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
</svg>
) : (
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h9m5-4v12m0 0l-4-4m4 4l4-4" />
</svg>
)}
</button>
</div>
</div>
{/* Date Range Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Start Date
</label>
<input
type="date"
value={filters.dateRange?.start || ''}
onChange={(e) => handleDateRangeChange(e.target.value, filters.dateRange?.end || '')}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
End Date
</label>
<input
type="date"
value={filters.dateRange?.end || ''}
onChange={(e) => handleDateRangeChange(filters.dateRange?.start || '', e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
/>
</div>
</div>
{/* Clear Filters */}
{(filters.cameraName || filters.dateRange) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<button
onClick={() => setFilters({})}
className="inline-flex items-center px-4 py-2.5 text-sm font-medium transition rounded-lg border bg-white text-gray-700 border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Clear All Filters
</button>
</div>
)}
</div>
{/* Clear Filters */}
{(filters.cameraName || filters.dateRange) && (
<div className="mt-4 pt-4 border-t border-gray-100">
<button
onClick={() => setFilters({})}
className="inline-flex items-center px-3 py-2 text-sm font-medium transition rounded-lg border bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Clear Filters
</button>
</div>
)}
{/* Video List */}
<VideoList
filters={filters}
sortOptions={sortOptions}
onVideoSelect={handleVideoSelect}
limit={24}
/>
{/* Video Modal */}
<VideoModal
video={selectedVideo}
isOpen={isModalOpen}
onClose={handleModalClose}
/>
{/* Performance Dashboard (development only) */}
<PerformanceDashboard />
</div>
{/* Video List */}
<VideoList
filters={filters}
sortOptions={sortOptions}
onVideoSelect={handleVideoSelect}
limit={24}
/>
{/* Video Modal */}
<VideoModal
video={selectedVideo}
isOpen={isModalOpen}
onClose={handleModalClose}
/>
</div>
</VideoErrorBoundary>
);
};

View File

@@ -0,0 +1,133 @@
/**
* ApiStatusIndicator Component
*
* A component that displays the connection status of the video streaming API
* and provides helpful information when the API is not accessible.
*/
import React, { useState, useEffect } from 'react';
import { videoApiService } from '../services/videoApi';
interface ApiStatusIndicatorProps {
className?: string;
showDetails?: boolean;
}
export const ApiStatusIndicator: React.FC<ApiStatusIndicatorProps> = ({
className = '',
showDetails = false,
}) => {
const [isOnline, setIsOnline] = useState<boolean | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [lastChecked, setLastChecked] = useState<Date | null>(null);
const checkApiStatus = async () => {
setIsChecking(true);
try {
const status = await videoApiService.healthCheck();
setIsOnline(status);
setLastChecked(new Date());
} catch (error) {
setIsOnline(false);
setLastChecked(new Date());
} finally {
setIsChecking(false);
}
};
useEffect(() => {
checkApiStatus();
// Check status every 30 seconds
const interval = setInterval(checkApiStatus, 30000);
return () => clearInterval(interval);
}, []);
const getStatusColor = () => {
if (isChecking) return 'bg-yellow-500';
if (isOnline === null) return 'bg-gray-500';
return isOnline ? 'bg-green-500' : 'bg-red-500';
};
const getStatusText = () => {
if (isChecking) return 'Checking...';
if (isOnline === null) return 'Unknown';
return isOnline ? 'Connected' : 'Disconnected';
};
const getStatusIcon = () => {
if (isChecking) {
return (
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white"></div>
);
}
if (isOnline) {
return (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<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>
);
};
if (!showDetails) {
return (
<div className={`inline-flex items-center ${className}`}>
<div className={`w-2 h-2 rounded-full ${getStatusColor()} mr-2`}></div>
<span className="text-sm text-gray-600">{getStatusText()}</span>
</div>
);
}
return (
<div className={`bg-white border border-gray-200 rounded-lg p-4 ${className}`}>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-900">Video API Status</h3>
<button
onClick={checkApiStatus}
disabled={isChecking}
className="text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
Refresh
</button>
</div>
<div className="flex items-center mb-2">
<div className={`w-3 h-3 rounded-full ${getStatusColor()} mr-2 flex items-center justify-center text-white`}>
{getStatusIcon()}
</div>
<span className="text-sm font-medium">{getStatusText()}</span>
</div>
{lastChecked && (
<div className="text-xs text-gray-500">
Last checked: {lastChecked.toLocaleTimeString()}
</div>
)}
{isOnline === false && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
<div className="text-sm text-red-800">
<strong>Connection Failed</strong>
<p className="mt-1">
Cannot connect to the USDA Vision Camera System. Please ensure:
</p>
<ul className="mt-2 list-disc list-inside space-y-1">
<li>The vision system is running</li>
<li>The API is accessible at the configured URL</li>
<li>Network connectivity is available</li>
</ul>
</div>
</div>
)}
</div>
);
};

View File

@@ -51,13 +51,13 @@ export const Pagination: React.FC<PaginationProps> = ({
const isLastPage = currentPage === totalPages;
// Button base classes matching dashboard template
const baseButtonClasses = "inline-flex items-center justify-center px-3 py-2 text-sm font-medium transition rounded-lg border";
const baseButtonClasses = "inline-flex items-center justify-center px-3 py-2 text-sm font-medium transition-all duration-200 rounded-lg border";
// Active page button classes
const activeButtonClasses = "bg-brand-500 text-white border-brand-500 hover:bg-brand-600 shadow-theme-xs";
const activeButtonClasses = "bg-brand-500 text-white border-brand-500 hover:bg-brand-600 shadow-theme-sm";
// Inactive page button classes
const inactiveButtonClasses = "bg-white text-gray-700 border-gray-300 hover:bg-gray-50";
const inactiveButtonClasses = "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:border-gray-400 shadow-theme-xs";
// Disabled button classes
const disabledButtonClasses = "bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed opacity-50";

View File

@@ -0,0 +1,167 @@
/**
* PerformanceDashboard Component
*
* A development tool for monitoring video streaming performance.
* Only shown in development mode.
*/
import React, { useState, useEffect } from 'react';
import { performanceMonitor, thumbnailCache } from '../utils';
interface PerformanceDashboardProps {
className?: string;
}
export const PerformanceDashboard: React.FC<PerformanceDashboardProps> = ({
className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [stats, setStats] = useState<any>(null);
const [cacheStats, setCacheStats] = useState<any>(null);
useEffect(() => {
if (isOpen) {
const updateStats = () => {
setStats({
overall: performanceMonitor.getStats(),
getVideos: performanceMonitor.getStats('get_videos'),
getThumbnail: performanceMonitor.getStats('get_thumbnail'),
recentMetrics: performanceMonitor.getRecentMetrics(5),
});
setCacheStats(thumbnailCache.getStats());
};
updateStats();
const interval = setInterval(updateStats, 2000);
return () => clearInterval(interval);
}
}, [isOpen]);
// Only show in development
if (process.env.NODE_ENV !== 'development') {
return null;
}
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className={`fixed bottom-4 right-4 bg-blue-600 text-white p-2 rounded-full shadow-lg hover:bg-blue-700 transition-colors z-50 ${className}`}
title="Open Performance Dashboard"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</button>
);
}
return (
<div className={`fixed bottom-4 right-4 bg-white border border-gray-200 rounded-lg shadow-xl p-4 max-w-md w-80 max-h-96 overflow-y-auto z-50 ${className}`}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Performance</h3>
<button
onClick={() => setIsOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<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>
{stats && (
<div className="space-y-4">
{/* Overall Stats */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Overall</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>Operations: {stats.overall.totalOperations}</div>
<div>Success: {(stats.overall.successRate * 100).toFixed(1)}%</div>
<div>Avg: {stats.overall.averageDuration.toFixed(0)}ms</div>
<div>Max: {stats.overall.maxDuration.toFixed(0)}ms</div>
</div>
</div>
{/* Video Loading Stats */}
{stats.getVideos.totalOperations > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Video Loading</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>Calls: {stats.getVideos.totalOperations}</div>
<div>Success: {(stats.getVideos.successRate * 100).toFixed(1)}%</div>
<div>Avg: {stats.getVideos.averageDuration.toFixed(0)}ms</div>
<div>Max: {stats.getVideos.maxDuration.toFixed(0)}ms</div>
</div>
</div>
)}
{/* Thumbnail Stats */}
{stats.getThumbnail.totalOperations > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Thumbnails</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>Calls: {stats.getThumbnail.totalOperations}</div>
<div>Success: {(stats.getThumbnail.successRate * 100).toFixed(1)}%</div>
<div>Avg: {stats.getThumbnail.averageDuration.toFixed(0)}ms</div>
<div>Max: {stats.getThumbnail.maxDuration.toFixed(0)}ms</div>
</div>
</div>
)}
{/* Cache Stats */}
{cacheStats && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Cache</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>Cached: {cacheStats.size}</div>
<div>Memory: {(cacheStats.totalMemory / 1024 / 1024).toFixed(1)}MB</div>
<div>Hits: {cacheStats.totalAccess}</div>
<div>Avg Size: {(cacheStats.averageSize / 1024).toFixed(0)}KB</div>
</div>
</div>
)}
{/* Recent Operations */}
{stats.recentMetrics.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Recent</h4>
<div className="space-y-1">
{stats.recentMetrics.map((metric: any, index: number) => (
<div key={index} className="flex justify-between text-xs">
<span className={metric.success ? 'text-green-600' : 'text-red-600'}>
{metric.operation}
</span>
<span>{metric.duration?.toFixed(0)}ms</span>
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex space-x-2">
<button
onClick={() => {
performanceMonitor.clear();
thumbnailCache.clear();
}}
className="flex-1 px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
>
Clear All
</button>
<button
onClick={() => {
console.log(performanceMonitor.getReport());
console.log('Cache Stats:', thumbnailCache.getStats());
}}
className="flex-1 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
Log Report
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -66,7 +66,7 @@ export const VideoCard: React.FC<VideoCardProps> = ({
</div>
{/* Streamable Indicator */}
{video.is_streamable && (
{video.is_streamable ? (
<div className="absolute bottom-2 left-2">
<div className="bg-green-500 text-white text-xs px-2 py-1 rounded flex items-center">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
@@ -75,6 +75,15 @@ export const VideoCard: React.FC<VideoCardProps> = ({
Streamable
</div>
</div>
) : (
<div className="absolute bottom-2 left-2">
<div className="bg-yellow-500 text-white text-xs px-2 py-1 rounded flex items-center">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
Processing
</div>
</div>
)}
{/* Conversion Needed Indicator */}

View File

@@ -0,0 +1,196 @@
/**
* VideoDebugger Component
*
* A development tool for debugging video streaming issues.
* Provides direct access to test video URLs and diagnose problems.
*/
import React, { useState } from 'react';
import { videoApiService } from '../services/videoApi';
interface VideoDebuggerProps {
fileId: string;
className?: string;
}
export const VideoDebugger: React.FC<VideoDebuggerProps> = ({
fileId,
className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [testResults, setTestResults] = useState<any>(null);
const [isLoading, setIsLoading] = useState(false);
const streamingUrl = videoApiService.getStreamingUrl(fileId);
const thumbnailUrl = videoApiService.getThumbnailUrl(fileId);
const runDiagnostics = async () => {
setIsLoading(true);
const results: any = {
timestamp: new Date().toISOString(),
fileId,
streamingUrl,
thumbnailUrl,
tests: {}
};
try {
// Test 1: Video Info
try {
const videoInfo = await videoApiService.getVideoInfo(fileId);
results.tests.videoInfo = { success: true, data: videoInfo };
} catch (error) {
results.tests.videoInfo = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
// Test 2: Streaming Info
try {
const streamingInfo = await videoApiService.getStreamingInfo(fileId);
results.tests.streamingInfo = { success: true, data: streamingInfo };
} catch (error) {
results.tests.streamingInfo = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
// Test 3: HEAD request to streaming URL
try {
const response = await fetch(streamingUrl, { method: 'HEAD' });
results.tests.streamingHead = {
success: response.ok,
status: response.status,
headers: Object.fromEntries(response.headers.entries())
};
} catch (error) {
results.tests.streamingHead = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
// Test 4: Range request test
try {
const response = await fetch(streamingUrl, {
headers: { 'Range': 'bytes=0-1023' }
});
results.tests.rangeRequest = {
success: response.ok,
status: response.status,
supportsRanges: response.headers.get('accept-ranges') === 'bytes',
contentRange: response.headers.get('content-range')
};
} catch (error) {
results.tests.rangeRequest = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
// Test 5: Thumbnail test
try {
const response = await fetch(thumbnailUrl, { method: 'HEAD' });
results.tests.thumbnail = {
success: response.ok,
status: response.status,
contentType: response.headers.get('content-type')
};
} catch (error) {
results.tests.thumbnail = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
} catch (error) {
results.error = error instanceof Error ? error.message : 'Unknown error';
}
setTestResults(results);
setIsLoading(false);
};
// Only show in development
if (process.env.NODE_ENV !== 'development') {
return null;
}
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className={`px-3 py-1 text-xs bg-yellow-100 text-yellow-800 rounded hover:bg-yellow-200 transition-colors ${className}`}
title="Open Video Debugger"
>
🔧 Debug
</button>
);
}
return (
<div className={`bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-2xl ${className}`}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Video Debugger</h3>
<button
onClick={() => setIsOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="space-y-4">
{/* Basic Info */}
<div>
<h4 className="font-medium text-gray-700 mb-2">Basic Info</h4>
<div className="text-sm space-y-1">
<div><strong>File ID:</strong> {fileId}</div>
<div><strong>Streaming URL:</strong> <a href={streamingUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{streamingUrl}</a></div>
<div><strong>Thumbnail URL:</strong> <a href={thumbnailUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{thumbnailUrl}</a></div>
</div>
</div>
{/* Quick Actions */}
<div>
<h4 className="font-medium text-gray-700 mb-2">Quick Actions</h4>
<div className="flex space-x-2">
<button
onClick={runDiagnostics}
disabled={isLoading}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Running...' : 'Run Diagnostics'}
</button>
<button
onClick={() => window.open(streamingUrl, '_blank')}
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700"
>
Open Video
</button>
<button
onClick={() => window.open(thumbnailUrl, '_blank')}
className="px-3 py-1 text-sm bg-purple-600 text-white rounded hover:bg-purple-700"
>
Open Thumbnail
</button>
</div>
</div>
{/* Test Results */}
{testResults && (
<div>
<h4 className="font-medium text-gray-700 mb-2">Diagnostic Results</h4>
<div className="bg-gray-50 rounded p-3 text-xs font-mono max-h-64 overflow-y-auto">
<pre>{JSON.stringify(testResults, null, 2)}</pre>
</div>
</div>
)}
{/* Native Video Test */}
<div>
<h4 className="font-medium text-gray-700 mb-2">Native Video Test</h4>
<video
controls
width="100%"
height="200"
className="border rounded"
onLoadStart={() => console.log('Native video load started')}
onLoadedData={() => console.log('Native video data loaded')}
onError={(e) => console.error('Native video error:', e)}
>
<source src={streamingUrl} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,146 @@
/**
* VideoErrorBoundary Component
*
* Error boundary specifically designed for video streaming components.
* Provides user-friendly error messages and recovery options.
*/
import React, { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
export class VideoErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null,
};
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.setState({
error,
errorInfo,
});
// Call the onError callback if provided
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
// Log error for debugging
console.error('Video streaming error:', error, errorInfo);
}
handleRetry = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render() {
if (this.state.hasError) {
// Use custom fallback if provided
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return (
<div className="min-h-[400px] flex items-center justify-center bg-gray-50 rounded-lg border border-gray-200">
<div className="text-center max-w-md mx-auto p-6">
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Video System Error
</h3>
<p className="text-gray-600 mb-4">
Something went wrong with the video streaming component. This might be due to:
</p>
<ul className="text-sm text-gray-500 text-left mb-6 space-y-1">
<li> Network connectivity issues</li>
<li> Video API server problems</li>
<li> Corrupted video files</li>
<li> Browser compatibility issues</li>
</ul>
<div className="space-y-3">
<button
onClick={this.handleRetry}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="w-full px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
>
Reload Page
</button>
</div>
{/* Error details for debugging (only in development) */}
{process.env.NODE_ENV === 'development' && this.state.error && (
<details className="mt-6 text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
Show Error Details
</summary>
<div className="mt-2 p-3 bg-gray-100 rounded text-xs font-mono text-gray-700 overflow-auto max-h-32">
<div className="font-semibold mb-1">Error:</div>
<div className="mb-2">{this.state.error.message}</div>
<div className="font-semibold mb-1">Stack:</div>
<div>{this.state.error.stack}</div>
</div>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}
// Higher-order component for easier usage
export function withVideoErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
fallback?: ReactNode
) {
return function WrappedComponent(props: P) {
return (
<VideoErrorBoundary fallback={fallback}>
<Component {...props} />
</VideoErrorBoundary>
);
};
}

View File

@@ -49,20 +49,19 @@ export const VideoList: React.FC<VideoListProps> = ({
autoFetch: true,
});
// Update filters when props change (but don't auto-fetch)
// Update filters when props change (without causing infinite loops)
useEffect(() => {
if (filters) {
setLocalFilters(filters);
}
}, [filters]);
// Update sort when props change
// Update sort when props change (without causing infinite loops)
useEffect(() => {
if (sortOptions) {
setLocalSort(sortOptions);
updateSort(sortOptions);
}
}, [sortOptions, updateSort]);
}, [sortOptions]);
const handleVideoClick = (video: any) => {
if (onVideoSelect) {
@@ -134,6 +133,31 @@ export const VideoList: React.FC<VideoListProps> = ({
return (
<div className={containerClasses}>
{/* Top Pagination */}
{totalPages > 1 && (
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white rounded-xl border border-gray-200 shadow-theme-sm">
{/* Page Info */}
<PageInfo
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalCount}
itemsPerPage={limit}
className="text-sm text-gray-600"
/>
{/* Pagination Controls */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={goToPage}
showFirstLast={true}
showPrevNext={true}
maxVisiblePages={5}
className="justify-center sm:justify-end"
/>
</div>
)}
{/* Results Summary */}
<div className="flex items-center justify-between mb-6">
<div className="text-sm text-gray-600">
@@ -147,7 +171,7 @@ export const VideoList: React.FC<VideoListProps> = ({
<button
onClick={refetch}
disabled={loading === 'loading'}
className="inline-flex items-center px-3 py-2 text-sm font-medium transition rounded-lg border bg-white text-gray-700 border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="inline-flex items-center px-3 py-2 text-sm font-medium transition rounded-lg border bg-white text-gray-700 border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed shadow-theme-xs"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
@@ -168,16 +192,16 @@ export const VideoList: React.FC<VideoListProps> = ({
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-8 space-y-4">
{/* Bottom Pagination */}
{totalPages > 1 && videos.length > 0 && (
<div className="mt-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white rounded-xl border border-gray-200 shadow-theme-sm">
{/* Page Info */}
<PageInfo
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalCount}
itemsPerPage={limit}
className="text-center"
className="text-sm text-gray-600"
/>
{/* Pagination Controls */}
@@ -188,7 +212,7 @@ export const VideoList: React.FC<VideoListProps> = ({
showFirstLast={true}
showPrevNext={true}
maxVisiblePages={5}
className="justify-center"
className="justify-center sm:justify-end"
/>
</div>
)}

View File

@@ -7,6 +7,7 @@
import React, { useEffect } from 'react';
import { type VideoFile } from '../types';
import { VideoPlayer } from './VideoPlayer';
import { VideoDebugger } from './VideoDebugger';
import { useVideoInfo } from '../hooks/useVideoInfo';
import {
formatFileSize,
@@ -64,7 +65,7 @@ export const VideoModal: React.FC<VideoModalProps> = ({
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 z-[999999] overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-75 transition-opacity"
@@ -109,8 +110,8 @@ export const VideoModal: React.FC<VideoModalProps> = ({
{video.status}
</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isWebCompatible(video.format)
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'
}`}>
{getFormatDisplayName(video.format)}
</span>
@@ -219,6 +220,9 @@ export const VideoModal: React.FC<VideoModalProps> = ({
</div>
</div>
)}
{/* Video Debugger (development only) */}
<VideoDebugger fileId={video.file_id} />
</div>
</div>
</div>

View File

@@ -23,7 +23,7 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
onEnded,
onError,
}, forwardedRef) => {
const [videoInfo, setVideoInfo] = useState<{ filename?: string; mimeType: string }>({
const [videoInfo, setVideoInfo] = useState<{ filename?: string; mimeType: string; isStreamable?: boolean }>({
mimeType: 'video/mp4' // Default to MP4
});
@@ -40,7 +40,7 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
const streamingUrl = videoApiService.getStreamingUrl(fileId);
// Fetch video info to determine MIME type
// Fetch video info to determine MIME type and streamability
useEffect(() => {
const fetchVideoInfo = async () => {
try {
@@ -49,11 +49,16 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
// Extract filename from file_id or use a default pattern
const filename = info.file_id.includes('.') ? info.file_id : `${info.file_id}.mp4`;
const mimeType = getVideoMimeType(filename);
setVideoInfo({ filename, mimeType });
setVideoInfo({
filename,
mimeType,
isStreamable: info.is_streamable
});
}
} catch (error) {
console.warn('Could not fetch video info, using default MIME type:', error);
// Keep default MP4 MIME type
// Keep default MP4 MIME type, assume not streamable
setVideoInfo(prev => ({ ...prev, isStreamable: false }));
}
};
@@ -81,9 +86,10 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
<video
ref={ref}
className="w-full h-full bg-black"
controls={!controls} // Use native controls if custom controls are disabled
controls={!controls || state.error} // Use native controls if custom controls are disabled or there's an error
style={{ width, height }}
playsInline // Important for iOS compatibility
preload="metadata" // Load metadata first for better UX
>
<source src={streamingUrl} type={videoInfo.mimeType} />
{/* Fallback for MP4 if original format fails */}
@@ -96,7 +102,10 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
{/* Loading Overlay */}
{state.isLoading && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="text-white text-lg">Loading...</div>
<div className="text-white text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<div className="text-lg">Loading video...</div>
</div>
</div>
)}

View File

@@ -6,6 +6,7 @@
import React, { useState, useEffect } from 'react';
import { videoApiService } from '../services/videoApi';
import { thumbnailCache } from '../utils/thumbnailCache';
import { type VideoThumbnailProps } from '../types';
export const VideoThumbnail: React.FC<VideoThumbnailProps> = ({
@@ -29,6 +30,15 @@ export const VideoThumbnail: React.FC<VideoThumbnailProps> = ({
setIsLoading(true);
setError(null);
// Check cache first
const cachedUrl = thumbnailCache.get(fileId, timestamp, width, height);
if (cachedUrl && isMounted) {
setThumbnailUrl(cachedUrl);
setIsLoading(false);
return;
}
// Fetch from API if not cached
const blob = await videoApiService.getThumbnailBlob(fileId, {
timestamp,
width,
@@ -36,7 +46,8 @@ export const VideoThumbnail: React.FC<VideoThumbnailProps> = ({
});
if (isMounted) {
const url = URL.createObjectURL(blob);
// Store in cache and get URL
const url = thumbnailCache.set(fileId, timestamp, width, height, blob);
setThumbnailUrl(url);
setIsLoading(false);
}
@@ -52,20 +63,11 @@ export const VideoThumbnail: React.FC<VideoThumbnailProps> = ({
return () => {
isMounted = false;
if (thumbnailUrl) {
URL.revokeObjectURL(thumbnailUrl);
}
// Note: We don't revoke the URL here since it's managed by the cache
};
}, [fileId, timestamp, width, height]);
// Cleanup URL on unmount
useEffect(() => {
return () => {
if (thumbnailUrl) {
URL.revokeObjectURL(thumbnailUrl);
}
};
}, [thumbnailUrl]);
// Note: URL cleanup is now handled by the thumbnail cache
const handleClick = () => {
if (onClick && !isLoading && !error) {

View File

@@ -11,6 +11,10 @@ export { VideoCard } from './VideoCard';
export { VideoList } from './VideoList';
export { VideoModal } from './VideoModal';
export { Pagination, PageInfo } from './Pagination';
export { ApiStatusIndicator } from './ApiStatusIndicator';
export { VideoErrorBoundary, withVideoErrorBoundary } from './VideoErrorBoundary';
export { PerformanceDashboard } from './PerformanceDashboard';
export { VideoDebugger } from './VideoDebugger';
// Re-export component prop types for convenience
export type {

View File

@@ -57,9 +57,11 @@ export function useVideoList(options: UseVideoListOptions = {}) {
const [loading, setLoading] = useState<LoadingState>('idle');
const [error, setError] = useState<VideoError | null>(null);
const [hasMore, setHasMore] = useState(true);
const [currentParams, setCurrentParams] = useState<VideoListParams>(initialParams);
// Refs for cleanup and caching
const abortControllerRef = useRef<AbortController | null>(null);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
/**
@@ -154,9 +156,10 @@ export function useVideoList(options: UseVideoListOptions = {}) {
return;
}
const params = { ...initialParams, page, limit: initialParams.limit || 20 };
const params = { ...currentParams, page, limit: currentParams.limit || 20 };
setCurrentParams(params);
await fetchVideos(params, false);
}, [initialParams, totalPages, loading, fetchVideos]);
}, [currentParams, totalPages, loading, fetchVideos]);
/**
* Go to next page
@@ -189,6 +192,7 @@ export function useVideoList(options: UseVideoListOptions = {}) {
limit: initialParams.limit || 20,
};
setCurrentParams(newParams);
fetchVideos(newParams, false);
}, [initialParams, fetchVideos]);

View File

@@ -219,10 +219,29 @@ export function useVideoPlayer(options: UseVideoPlayerOptions = {}) {
const handleLoadStart = () => {
updateState({ isLoading: true, error: null });
// Set a timeout to detect if loading takes too long
const loadTimeout = setTimeout(() => {
if (video && video.readyState < 2) { // HAVE_CURRENT_DATA
updateState({
isLoading: false,
error: 'Video loading timeout. The video may not be accessible or there may be a network issue.'
});
}
}, 30000); // 30 second timeout
// Store timeout ID to clear it later
(video as any)._loadTimeout = loadTimeout;
};
const handleLoadedData = () => {
updateState({ isLoading: false });
// Clear the loading timeout
if ((video as any)._loadTimeout) {
clearTimeout((video as any)._loadTimeout);
(video as any)._loadTimeout = null;
}
};
const handleTimeUpdate = () => {
@@ -252,6 +271,12 @@ export function useVideoPlayer(options: UseVideoPlayerOptions = {}) {
const errorMessage = video.error?.message || 'Video playback error';
updateState({ isLoading: false, error: errorMessage, isPlaying: false });
onError?.(errorMessage);
// Clear the loading timeout
if ((video as any)._loadTimeout) {
clearTimeout((video as any)._loadTimeout);
(video as any)._loadTimeout = null;
}
};
const handleVolumeChange = () => {

View File

@@ -13,9 +13,11 @@ import {
type VideoListParams,
type ThumbnailParams,
} from '../types';
import { performanceMonitor } from '../utils/performanceMonitor';
// Configuration
const API_BASE_URL = 'http://vision:8000'; // Based on the test script
// Configuration - Use environment variable or default to vision container
// The API is accessible at vision:8000 in the current setup
const API_BASE_URL = import.meta.env.VITE_VISION_API_URL || 'http://vision:8000';
/**
* Custom error class for video API errors
@@ -85,11 +87,46 @@ export class VideoApiService {
this.baseUrl = baseUrl;
}
/**
* Get total count of videos with filters (without pagination)
*/
private totalCountCache = new Map<string, { count: number; timestamp: number }>();
private readonly CACHE_DURATION = 30000; // 30 seconds cache
private async getTotalCount(params: Omit<VideoListParams, 'limit' | 'offset' | 'page'>): Promise<number> {
// Create cache key from params
const cacheKey = JSON.stringify(params);
const cached = this.totalCountCache.get(cacheKey);
// Return cached result if still valid
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) {
return cached.count;
}
const queryString = buildQueryString({ ...params, limit: 1000 }); // Use high limit to get accurate total
const url = `${this.baseUrl}/videos/${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
const result = await handleApiResponse<VideoListResponse>(response);
const count = result.videos.length; // Since backend returns wrong total_count, count the actual videos
// Cache the result
this.totalCountCache.set(cacheKey, { count, timestamp: Date.now() });
return count;
}
/**
* Get list of videos with optional filtering
*/
async getVideos(params: VideoListParams = {}): Promise<VideoListResponse> {
try {
return performanceMonitor.trackOperation('get_videos', async () => {
// Convert page-based params to offset-based for API compatibility
const apiParams = { ...params };
@@ -113,9 +150,18 @@ export class VideoApiService {
// Add pagination metadata if page was requested
if (params.page && params.limit) {
const totalPages = Math.ceil(result.total_count / params.limit);
// Get accurate total count by calling without pagination
const totalCount = await this.getTotalCount({
camera_name: params.camera_name,
start_date: params.start_date,
end_date: params.end_date,
include_metadata: params.include_metadata,
});
const totalPages = Math.ceil(totalCount / params.limit);
return {
...result,
total_count: totalCount, // Use accurate total count
page: params.page,
total_pages: totalPages,
has_next: params.page < totalPages,
@@ -124,16 +170,7 @@ export class VideoApiService {
}
return result;
} catch (error) {
if (error instanceof VideoApiError) {
throw error;
}
throw new VideoApiError(
'NETWORK_ERROR',
'Failed to fetch videos',
{ originalError: error }
);
}
}, { params });
}
/**
@@ -205,7 +242,7 @@ export class VideoApiService {
* Download thumbnail as blob
*/
async getThumbnailBlob(fileId: string, params: ThumbnailParams = {}): Promise<Blob> {
try {
return performanceMonitor.trackOperation('get_thumbnail', async () => {
const url = this.getThumbnailUrl(fileId, params);
const response = await fetch(url);
@@ -218,16 +255,7 @@ export class VideoApiService {
}
return await response.blob();
} catch (error) {
if (error instanceof VideoApiError) {
throw error;
}
throw new VideoApiError(
'NETWORK_ERROR',
`Failed to fetch thumbnail for ${fileId}`,
{ originalError: error, fileId }
);
}
}, { fileId, params });
}
/**

View File

@@ -0,0 +1,9 @@
/**
* Video Streaming Utils - Index
*
* Centralized export for all video streaming utilities.
*/
export * from './videoUtils';
export * from './thumbnailCache';
export * from './performanceMonitor';

View File

@@ -0,0 +1,197 @@
/**
* Performance Monitor for Video Streaming
*
* Tracks and reports performance metrics for video streaming operations.
*/
interface PerformanceMetric {
operation: string;
startTime: number;
endTime?: number;
duration?: number;
success: boolean;
error?: string;
metadata?: Record<string, any>;
}
interface PerformanceStats {
totalOperations: number;
successfulOperations: number;
failedOperations: number;
averageDuration: number;
minDuration: number;
maxDuration: number;
successRate: number;
}
export class PerformanceMonitor {
private metrics: PerformanceMetric[] = [];
private maxMetrics: number;
constructor(maxMetrics: number = 1000) {
this.maxMetrics = maxMetrics;
}
/**
* Start tracking an operation
*/
startOperation(operation: string, metadata?: Record<string, any>): string {
const id = `${operation}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const metric: PerformanceMetric = {
operation,
startTime: performance.now(),
success: false,
metadata,
};
this.metrics.push(metric);
// Keep only the most recent metrics
if (this.metrics.length > this.maxMetrics) {
this.metrics.shift();
}
return id;
}
/**
* End tracking an operation
*/
endOperation(operation: string, success: boolean, error?: string): void {
const metric = this.metrics
.slice()
.reverse()
.find(m => m.operation === operation && !m.endTime);
if (metric) {
metric.endTime = performance.now();
metric.duration = metric.endTime - metric.startTime;
metric.success = success;
metric.error = error;
}
}
/**
* Track a complete operation
*/
async trackOperation<T>(
operation: string,
fn: () => Promise<T>,
metadata?: Record<string, any>
): Promise<T> {
this.startOperation(operation, metadata);
try {
const result = await fn();
this.endOperation(operation, true);
return result;
} catch (error) {
this.endOperation(operation, false, error instanceof Error ? error.message : 'Unknown error');
throw error;
}
}
/**
* Get performance statistics for a specific operation
*/
getStats(operation?: string): PerformanceStats {
const filteredMetrics = operation
? this.metrics.filter(m => m.operation === operation && m.duration !== undefined)
: this.metrics.filter(m => m.duration !== undefined);
if (filteredMetrics.length === 0) {
return {
totalOperations: 0,
successfulOperations: 0,
failedOperations: 0,
averageDuration: 0,
minDuration: 0,
maxDuration: 0,
successRate: 0,
};
}
const durations = filteredMetrics.map(m => m.duration!);
const successfulOps = filteredMetrics.filter(m => m.success).length;
const failedOps = filteredMetrics.length - successfulOps;
return {
totalOperations: filteredMetrics.length,
successfulOperations: successfulOps,
failedOperations: failedOps,
averageDuration: durations.reduce((sum, d) => sum + d, 0) / durations.length,
minDuration: Math.min(...durations),
maxDuration: Math.max(...durations),
successRate: successfulOps / filteredMetrics.length,
};
}
/**
* Get recent metrics
*/
getRecentMetrics(count: number = 10): PerformanceMetric[] {
return this.metrics
.filter(m => m.duration !== undefined)
.slice(-count)
.reverse();
}
/**
* Clear all metrics
*/
clear(): void {
this.metrics = [];
}
/**
* Export metrics for analysis
*/
exportMetrics(): PerformanceMetric[] {
return [...this.metrics];
}
/**
* Get a performance report
*/
getReport(): string {
const operations = [...new Set(this.metrics.map(m => m.operation))];
let report = 'Video Streaming Performance Report\n';
report += '=====================================\n\n';
for (const operation of operations) {
const stats = this.getStats(operation);
report += `${operation}:\n`;
report += ` Total Operations: ${stats.totalOperations}\n`;
report += ` Success Rate: ${(stats.successRate * 100).toFixed(1)}%\n`;
report += ` Average Duration: ${stats.averageDuration.toFixed(2)}ms\n`;
report += ` Min Duration: ${stats.minDuration.toFixed(2)}ms\n`;
report += ` Max Duration: ${stats.maxDuration.toFixed(2)}ms\n\n`;
}
return report;
}
}
// Create a singleton instance
export const performanceMonitor = new PerformanceMonitor();
// Helper functions for common operations
export const trackVideoLoad = (fileId: string) =>
performanceMonitor.startOperation('video_load', { fileId });
export const trackThumbnailLoad = (fileId: string, width: number, height: number) =>
performanceMonitor.startOperation('thumbnail_load', { fileId, width, height });
export const trackApiCall = (endpoint: string) =>
performanceMonitor.startOperation('api_call', { endpoint });
// Log performance stats periodically in development
if (process.env.NODE_ENV === 'development') {
setInterval(() => {
const stats = performanceMonitor.getStats();
if (stats.totalOperations > 0) {
console.log('Video Streaming Performance:', stats);
}
}, 60000); // Every minute
}

View File

@@ -0,0 +1,224 @@
/**
* Thumbnail Cache Utility
*
* Provides efficient caching for video thumbnails to improve performance
* and reduce API calls.
*/
interface CacheEntry {
blob: Blob;
url: string;
timestamp: number;
accessCount: number;
lastAccessed: number;
}
interface ThumbnailCacheOptions {
maxSize: number; // Maximum number of cached thumbnails
maxAge: number; // Maximum age in milliseconds
maxMemory: number; // Maximum memory usage in bytes
}
export class ThumbnailCache {
private cache = new Map<string, CacheEntry>();
private options: ThumbnailCacheOptions;
constructor(options: Partial<ThumbnailCacheOptions> = {}) {
this.options = {
maxSize: options.maxSize || 100,
maxAge: options.maxAge || 30 * 60 * 1000, // 30 minutes
maxMemory: options.maxMemory || 50 * 1024 * 1024, // 50MB
};
}
/**
* Generate cache key for a thumbnail
*/
private generateKey(fileId: string, timestamp: number, width: number, height: number): string {
return `${fileId}_${timestamp}_${width}x${height}`;
}
/**
* Get thumbnail from cache
*/
get(fileId: string, timestamp: number, width: number, height: number): string | null {
const key = this.generateKey(fileId, timestamp, width, height);
const entry = this.cache.get(key);
if (!entry) {
return null;
}
// Check if entry is expired
const now = Date.now();
if (now - entry.timestamp > this.options.maxAge) {
this.delete(key);
return null;
}
// Update access statistics
entry.accessCount++;
entry.lastAccessed = now;
return entry.url;
}
/**
* Store thumbnail in cache
*/
set(fileId: string, timestamp: number, width: number, height: number, blob: Blob): string {
const key = this.generateKey(fileId, timestamp, width, height);
const url = URL.createObjectURL(blob);
const now = Date.now();
// Clean up existing entry if it exists
const existingEntry = this.cache.get(key);
if (existingEntry) {
URL.revokeObjectURL(existingEntry.url);
}
// Create new entry
const entry: CacheEntry = {
blob,
url,
timestamp: now,
accessCount: 1,
lastAccessed: now,
};
this.cache.set(key, entry);
// Cleanup if necessary
this.cleanup();
return url;
}
/**
* Delete specific entry from cache
*/
delete(key: string): boolean {
const entry = this.cache.get(key);
if (entry) {
URL.revokeObjectURL(entry.url);
return this.cache.delete(key);
}
return false;
}
/**
* Clear all cached thumbnails
*/
clear(): void {
for (const entry of this.cache.values()) {
URL.revokeObjectURL(entry.url);
}
this.cache.clear();
}
/**
* Get cache statistics
*/
getStats() {
const entries = Array.from(this.cache.values());
const totalSize = entries.reduce((sum, entry) => sum + entry.blob.size, 0);
const totalAccess = entries.reduce((sum, entry) => sum + entry.accessCount, 0);
return {
size: this.cache.size,
totalMemory: totalSize,
totalAccess,
averageSize: entries.length > 0 ? totalSize / entries.length : 0,
averageAccess: entries.length > 0 ? totalAccess / entries.length : 0,
};
}
/**
* Cleanup expired and least used entries
*/
private cleanup(): void {
const now = Date.now();
const entries = Array.from(this.cache.entries());
// Remove expired entries
for (const [key, entry] of entries) {
if (now - entry.timestamp > this.options.maxAge) {
this.delete(key);
}
}
// Check if we need to remove more entries
if (this.cache.size <= this.options.maxSize) {
const stats = this.getStats();
if (stats.totalMemory <= this.options.maxMemory) {
return; // No cleanup needed
}
}
// Sort by access frequency and recency (LRU with access count)
const sortedEntries = Array.from(this.cache.entries()).sort(([, a], [, b]) => {
// Prioritize by access count, then by last accessed time
const scoreA = a.accessCount * 1000 + (a.lastAccessed / 1000);
const scoreB = b.accessCount * 1000 + (b.lastAccessed / 1000);
return scoreA - scoreB; // Ascending order (least valuable first)
});
// Remove least valuable entries until we're under limits
while (
(this.cache.size > this.options.maxSize ||
this.getStats().totalMemory > this.options.maxMemory) &&
sortedEntries.length > 0
) {
const [key] = sortedEntries.shift()!;
this.delete(key);
}
}
/**
* Preload thumbnails for a list of videos
*/
async preload(
videos: Array<{ file_id: string }>,
getThumbnailBlob: (fileId: string, params: any) => Promise<Blob>,
options: { timestamp?: number; width?: number; height?: number } = {}
): Promise<void> {
const { timestamp = 1.0, width = 320, height = 240 } = options;
const promises = videos.slice(0, 10).map(async (video) => {
const key = this.generateKey(video.file_id, timestamp, width, height);
// Skip if already cached
if (this.cache.has(key)) {
return;
}
try {
const blob = await getThumbnailBlob(video.file_id, {
timestamp,
width,
height,
});
this.set(video.file_id, timestamp, width, height, blob);
} catch (error) {
// Silently fail for preloading
console.warn(`Failed to preload thumbnail for ${video.file_id}:`, error);
}
});
await Promise.allSettled(promises);
}
}
// Create a singleton instance
export const thumbnailCache = new ThumbnailCache({
maxSize: 100,
maxAge: 30 * 60 * 1000, // 30 minutes
maxMemory: 50 * 1024 * 1024, // 50MB
});
// Cleanup on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
thumbnailCache.clear();
});
}

View File

@@ -1,6 +1,7 @@
// Vision System API Client
// Base URL for the vision system API
const VISION_API_BASE_URL = 'http://vision:8000'
// Base URL for the vision system API - Use environment variable or default to vision container
// The API is accessible at vision:8000 in the current setup
const VISION_API_BASE_URL = import.meta.env.VITE_VISION_API_URL || 'http://vision:8000'
// Types based on the API documentation
export interface SystemStatus {

View File

@@ -0,0 +1,156 @@
/**
* Video Streaming API Test
*
* This test script verifies the video streaming functionality
* and API connectivity with the USDA Vision Camera System.
*/
import { videoApiService } from '../features/video-streaming/services/videoApi';
export interface TestResult {
test: string;
success: boolean;
message: string;
data?: any;
}
export class VideoStreamingTester {
private results: TestResult[] = [];
async runAllTests(): Promise<TestResult[]> {
this.results = [];
console.log('🧪 Starting Video Streaming API Tests');
console.log('=====================================');
await this.testApiConnectivity();
await this.testVideoList();
await this.testVideoInfo();
await this.testStreamingUrls();
console.log('\n📊 Test Results Summary:');
console.log('========================');
const passed = this.results.filter(r => r.success).length;
const total = this.results.length;
this.results.forEach(result => {
const icon = result.success ? '✅' : '❌';
console.log(`${icon} ${result.test}: ${result.message}`);
});
console.log(`\n🎯 Tests Passed: ${passed}/${total}`);
return this.results;
}
private async testApiConnectivity(): Promise<void> {
try {
console.log('\n🔗 Testing API Connectivity...');
const isHealthy = await videoApiService.healthCheck();
if (isHealthy) {
this.addResult('API Connectivity', true, 'Successfully connected to video API');
} else {
this.addResult('API Connectivity', false, 'API is not responding');
}
} catch (error) {
this.addResult('API Connectivity', false, `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async testVideoList(): Promise<void> {
try {
console.log('\n📋 Testing Video List...');
const response = await videoApiService.getVideos({
limit: 5,
include_metadata: true
});
if (response && typeof response.total_count === 'number') {
this.addResult('Video List', true, `Found ${response.total_count} videos, retrieved ${response.videos.length} items`, response);
} else {
this.addResult('Video List', false, 'Invalid response format');
}
} catch (error) {
this.addResult('Video List', false, `Failed to fetch videos: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async testVideoInfo(): Promise<void> {
try {
console.log('\n📹 Testing Video Info...');
// First get a video list to test with
const videoList = await videoApiService.getVideos({ limit: 1 });
if (videoList.videos.length === 0) {
this.addResult('Video Info', false, 'No videos available to test with');
return;
}
const firstVideo = videoList.videos[0];
const videoInfo = await videoApiService.getVideoInfo(firstVideo.file_id);
if (videoInfo && videoInfo.file_id === firstVideo.file_id) {
this.addResult('Video Info', true, `Successfully retrieved info for ${firstVideo.file_id}`, videoInfo);
} else {
this.addResult('Video Info', false, 'Invalid video info response');
}
} catch (error) {
this.addResult('Video Info', false, `Failed to fetch video info: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async testStreamingUrls(): Promise<void> {
try {
console.log('\n🎬 Testing Streaming URLs...');
// Get a video to test with
const videoList = await videoApiService.getVideos({ limit: 1 });
if (videoList.videos.length === 0) {
this.addResult('Streaming URLs', false, 'No videos available to test with');
return;
}
const firstVideo = videoList.videos[0];
// Test streaming URL generation
const streamingUrl = videoApiService.getStreamingUrl(firstVideo.file_id);
const thumbnailUrl = videoApiService.getThumbnailUrl(firstVideo.file_id, {
timestamp: 1.0,
width: 320,
height: 240
});
if (streamingUrl && thumbnailUrl) {
this.addResult('Streaming URLs', true, `Generated URLs for ${firstVideo.file_id}`, {
streamingUrl,
thumbnailUrl
});
} else {
this.addResult('Streaming URLs', false, 'Failed to generate URLs');
}
} catch (error) {
this.addResult('Streaming URLs', false, `Failed to test URLs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private addResult(test: string, success: boolean, message: string, data?: any): void {
this.results.push({ test, success, message, data });
}
}
// Export for use in browser console
if (typeof window !== 'undefined') {
(window as any).VideoStreamingTester = VideoStreamingTester;
(window as any).runVideoStreamingTests = async () => {
const tester = new VideoStreamingTester();
return await tester.runAllTests();
};
}
export default VideoStreamingTester;