Refactor video streaming feature and update dependencies

- Replaced npm ci with npm install in docker-compose for better package management.
- Introduced remote component loading for the VideoStreamingPage with error handling.
- Updated the title in index.html to "Experiments Dashboard" for clarity.
- Added new video remote service configuration in docker-compose for improved integration.
- Removed deprecated files and components related to the video streaming feature to streamline the codebase.
- Updated package.json and package-lock.json to include @originjs/vite-plugin-federation for module federation support.
This commit is contained in:
salirezav
2025-10-30 15:36:19 -04:00
parent 9f669e7dff
commit 0b724fe59b
102 changed files with 4656 additions and 13376 deletions

14
video-remote/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>video-remote</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3985
video-remote/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
video-remote/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "video-remote",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve:dist": "serve -s dist -l 3001",
"preview": "vite preview --port 3001"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.3.3",
"@vitejs/plugin-react": "^4.6.0",
"@tailwindcss/vite": "^4.1.11",
"tailwindcss": "^4.1.11",
"http-server": "^14.1.1",
"serve": "^14.2.3",
"vite": "^7.0.4",
"typescript": "~5.8.3"
}
}

20
video-remote/src/App.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react'
import { useState } from 'react'
import { VideoList } from './components/VideoList'
import { VideoModal } from './components/VideoModal'
const App: React.FC = () => {
const [selected, setSelected] = useState<string | null>(null)
return (
<div className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Video Library</h1>
<VideoList onSelect={(id) => setSelected(id)} />
<VideoModal fileId={selected} onClose={() => setSelected(null)} />
</div>
)
}
export default App

View File

@@ -0,0 +1,36 @@
import React, { useState } from 'react'
type Props = {
onChange: (filters: { camera_name?: string; start_date?: string; end_date?: string }) => void
}
export const FiltersBar: React.FC<Props> = ({ onChange }) => {
const [camera, setCamera] = useState('')
const [start, setStart] = useState('')
const [end, setEnd] = useState('')
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1">Camera</label>
<input value={camera} onChange={e=>setCamera(e.target.value)} placeholder="camera1" className="w-full px-3 py-2 border rounded" />
</div>
<div>
<label className="block text-sm mb-1">Start date</label>
<input type="date" value={start} onChange={e=>setStart(e.target.value)} className="w-full px-3 py-2 border rounded" />
</div>
<div>
<label className="block text-sm mb-1">End date</label>
<input type="date" value={end} onChange={e=>setEnd(e.target.value)} className="w-full px-3 py-2 border rounded" />
</div>
<div className="flex items-end gap-2">
<button className="px-3 py-2 border rounded" onClick={()=>onChange({ camera_name: camera || undefined, start_date: start || undefined, end_date: end || undefined })}>Apply</button>
<button className="px-3 py-2 border rounded" onClick={()=>{ setCamera(''); setStart(''); setEnd(''); onChange({}) }}>Clear</button>
</div>
</div>
)
}
export default FiltersBar

View File

@@ -0,0 +1,22 @@
import React from 'react'
type Props = {
page: number
totalPages: number
onChange: (page: number) => void
}
export const Pagination: React.FC<Props> = ({ page, totalPages, onChange }) => {
if (totalPages <= 1) return null
return (
<div className="flex items-center gap-2">
<button disabled={page<=1} onClick={()=>onChange(page-1)} className="px-3 py-1 border rounded disabled:opacity-50">Prev</button>
<span className="text-sm">Page {page} / {totalPages}</span>
<button disabled={page>=totalPages} onClick={()=>onChange(page+1)} className="px-3 py-1 border rounded disabled:opacity-50">Next</button>
</div>
)
}
export default Pagination

View File

@@ -0,0 +1,42 @@
import React from 'react'
import { VideoThumbnail } from './VideoThumbnail'
export type VideoFile = {
file_id: string
filename: string
camera_name: string
file_size_bytes: number
created_at: string
status: string
format: string
is_streamable?: boolean
needs_conversion?: boolean
metadata?: { duration_seconds: number; width: number; height: number; fps: number; codec: string }
}
export type VideoCardProps = {
video: VideoFile
onClick?: (video: VideoFile) => void
showMetadata?: boolean
className?: string
}
export const VideoCard: React.FC<VideoCardProps> = ({ video, onClick }) => {
return (
<div
className={`bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
onClick={onClick ? () => onClick(video) : undefined}
>
<VideoThumbnail fileId={video.file_id} width={640} height={360} className="w-full h-auto" alt={video.filename} />
<div className="p-4">
<div className="text-sm text-gray-500 mb-1">{video.camera_name}</div>
<div className="font-semibold text-gray-900 truncate" title={video.filename}>{video.filename}</div>
</div>
</div>
)
}
export default VideoCard

View File

@@ -0,0 +1,52 @@
import React, { useEffect, useState } from 'react'
import { fetchVideos } from '../services/video'
import { Pagination } from './Pagination'
import VideoCard from './VideoCard'
import { FiltersBar } from './FiltersBar'
type Props = { onSelect?: (fileId: string) => void }
export const VideoList: React.FC<Props> = ({ onSelect }) => {
const [videos, setVideos] = useState<any[]>([])
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filters, setFilters] = useState<{ camera_name?: string; start_date?: string; end_date?: string }>({})
async function load(p: number, f = filters) {
setLoading(true)
setError(null)
try {
const res = await fetchVideos({ page: p, limit: 12, ...f })
setVideos(res.videos)
setTotalPages(res.total_pages || 1)
} catch (e: any) {
setError(e?.message || 'Failed to load videos')
} finally {
setLoading(false)
}
}
useEffect(() => { load(page) }, [page])
if (loading) return <div className="p-6">Loading videos</div>
if (error) return <div className="p-6 text-red-600">{error}</div>
return (
<div className="space-y-6">
<FiltersBar onChange={(f) => { setFilters(f); setPage(1); load(1, f) }} />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{videos.map(v => (
<VideoCard key={v.file_id} video={v} onClick={onSelect ? () => onSelect(v.file_id) : undefined} />
))}
</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</div>
)
}
export default VideoList

View File

@@ -0,0 +1,29 @@
import React from 'react'
const BASE = (import.meta as any).env?.VITE_VISION_API_URL || '/api'
type Props = {
fileId: string | null
onClose: () => void
}
export const VideoModal: React.FC<Props> = ({ fileId, onClose }) => {
if (!fileId) return null
const src = `${BASE}/videos/${fileId}/stream`
return (
<div className="fixed inset-0 z-[1000] bg-black/50 flex items-center justify-center">
<div className="bg-white rounded-lg w-full max-w-3xl overflow-hidden">
<div className="flex items-center justify-between p-3 border-b">
<div className="font-semibold">Video</div>
<button onClick={onClose} className="px-2 py-1 border rounded">Close</button>
</div>
<div className="p-3">
<video src={src} controls className="w-full h-auto" />
</div>
</div>
</div>
)
}
export default VideoModal

View File

@@ -0,0 +1,26 @@
import React from 'react'
const BASE = (import.meta as any).env?.VITE_VISION_API_URL || '/api'
type Props = {
fileId: string
width?: number
height?: number
alt?: string
className?: string
onClick?: () => void
}
export const VideoThumbnail: React.FC<Props> = ({ fileId, width = 320, height = 180, alt = '', className = '', onClick }) => {
const p = new URLSearchParams()
if (width) p.append('width', String(width))
if (height) p.append('height', String(height))
const qs = p.toString()
const src = `${BASE}/videos/${fileId}/thumbnail${qs ? `?${qs}` : ''}`
return (
<img src={src} alt={alt} width={width} height={height} className={className} onClick={onClick} />
)
}
export default VideoThumbnail

View File

@@ -0,0 +1,3 @@
@import "tailwindcss";

View File

@@ -0,0 +1,9 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
const root = createRoot(document.getElementById('root')!)
root.render(<App />)

View File

@@ -0,0 +1,24 @@
type VideoListParams = { camera_name?: string; start_date?: string; end_date?: string; limit?: number; page?: number; offset?: number; include_metadata?: boolean }
type VideoFile = { file_id: string; camera_name: string; filename: string; file_size_bytes: number; format: string; status: string; created_at: string; is_streamable: boolean; needs_conversion: boolean }
type VideoListResponse = { videos: VideoFile[]; total_count: number; page?: number; total_pages?: number }
const BASE = (import.meta as any).env?.VITE_VISION_API_URL || '/api'
async function get<T>(path: string): Promise<T> {
const res = await fetch(path, { headers: { 'Accept': 'application/json' } })
if (!res.ok) throw new Error(`Request failed: ${res.status}`)
return res.json() as Promise<T>
}
export async function fetchVideos(params: VideoListParams = {}): Promise<VideoListResponse> {
const search = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null) search.append(k, String(v))
})
const qs = search.toString()
return get(`${BASE}/videos/${qs ? `?${qs}` : ''}`)
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"moduleResolution": "bundler",
"strict": true,
"module": "ESNext",
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@originjs/vite-plugin-federation'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
federation({
name: 'videoRemote',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.tsx',
'./VideoCard': './src/components/VideoCard.tsx',
},
shared: {
react: { singleton: true, eager: true },
'react-dom': { singleton: true, eager: true },
},
}),
],
server: {
port: 3001,
host: '0.0.0.0',
allowedHosts: ['exp-dash', 'localhost'],
cors: true
},
build: {
target: 'esnext',
},
})