generated from CubeCraft-Creations/Tracehound
CUB-195: add useSSE hook and Zustand camera store for live SSE events #2
Generated
+33
-3
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
@@ -1395,7 +1396,7 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -2071,7 +2072,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
@@ -3855,6 +3856,35 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
|
||||||
|
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
export { useCameraStatus } from './useCameraStatus'
|
export { useCameraStatus } from './useCameraStatus'
|
||||||
export { useSystemHealth } from './useSystemHealth'
|
export { useSystemHealth } from './useSystemHealth'
|
||||||
|
export { useSSE } from './useSSE'
|
||||||
|
export type { SSEConnectionState } from './useSSE'
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useCameraStore } from '../store/useCameraStore'
|
||||||
|
import type { CameraStatus } from '../types'
|
||||||
|
|
||||||
|
/** Shape of events sent by the Go SSE hub */
|
||||||
|
interface SSEEvent {
|
||||||
|
type: string
|
||||||
|
ts: string
|
||||||
|
payload?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connection states for the SSE stream */
|
||||||
|
export type SSEConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||||
|
|
||||||
|
interface UseSSEOptions {
|
||||||
|
/** Override the SSE endpoint (defaults to VITE_API_URL + /events/stream) */
|
||||||
|
endpoint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSSEReturn {
|
||||||
|
/** Current connection state */
|
||||||
|
connectionState: SSEConnectionState
|
||||||
|
/** Last event timestamp */
|
||||||
|
lastEventTs: string | null
|
||||||
|
/** Manually close and reopen the connection */
|
||||||
|
reconnect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ENDPOINT = `${import.meta.env.VITE_API_URL || '/api/v1'}/events/stream`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `useSSE` connects to the RemoteRig SSE endpoint at `/api/v1/events/stream`
|
||||||
|
* and updates the shared camera store (`useCameraStore`) with live events.
|
||||||
|
*
|
||||||
|
* Handled event types:
|
||||||
|
* - `connected` — initial handshake
|
||||||
|
* - `camera_status` — full camera status snapshot
|
||||||
|
* - `camera_online` / `camera_offline` — online/offline state change
|
||||||
|
* - `recording_event` — recording started or stopped
|
||||||
|
*
|
||||||
|
* EventSource auto-reconnects natively; the hook also exposes a `reconnect()`
|
||||||
|
* helper for manual reconnect.
|
||||||
|
*/
|
||||||
|
export function useSSE(opts: UseSSEOptions = {}): UseSSEReturn {
|
||||||
|
const endpoint = opts.endpoint ?? DEFAULT_ENDPOINT
|
||||||
|
const store = useCameraStore()
|
||||||
|
|
||||||
|
const connectionStateRef = useRef<SSEConnectionState>('disconnected')
|
||||||
|
const lastEventTsRef = useRef<string | null>(null)
|
||||||
|
const esRef = useRef<EventSource | null>(null)
|
||||||
|
|
||||||
|
// Derive a stable reference for connection state that doesn't trigger re-renders
|
||||||
|
// across every event. We return it via a getter callback but the store change
|
||||||
|
// re-renders are handled by Zustand selectors in consuming components.
|
||||||
|
|
||||||
|
const handleEvent = useCallback(
|
||||||
|
(event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data: SSEEvent = JSON.parse(event.data)
|
||||||
|
lastEventTsRef.current = data.ts
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'connected': {
|
||||||
|
// Initial handshake — no state update needed
|
||||||
|
connectionStateRef.current = 'connected'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'camera_status': {
|
||||||
|
// Full camera status payload — upsert into the store
|
||||||
|
const cam = data.payload as CameraStatus | undefined
|
||||||
|
if (cam?.camera_id) {
|
||||||
|
store.updateCamera(cam)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'camera_online': {
|
||||||
|
const pl = data.payload as { camera_id?: string } | undefined
|
||||||
|
if (pl?.camera_id) {
|
||||||
|
store.setOnline(pl.camera_id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'camera_offline': {
|
||||||
|
const pl = data.payload as { camera_id?: string } | undefined
|
||||||
|
if (pl?.camera_id) {
|
||||||
|
store.setOffline(pl.camera_id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'recording_event': {
|
||||||
|
const pl = data.payload as
|
||||||
|
| { camera_id?: string; recording?: boolean }
|
||||||
|
| undefined
|
||||||
|
if (pl?.camera_id && typeof pl.recording === 'boolean') {
|
||||||
|
store.setRecording(pl.camera_id, pl.recording)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown event type — ignore gracefully
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON — ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
)
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
// Close existing connection if any
|
||||||
|
esRef.current?.close()
|
||||||
|
|
||||||
|
connectionStateRef.current = 'connecting'
|
||||||
|
const es = new EventSource(endpoint)
|
||||||
|
esRef.current = es
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
connectionStateRef.current = 'connected'
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onmessage = handleEvent
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
connectionStateRef.current =
|
||||||
|
es.readyState === EventSource.CLOSED ? 'disconnected' : 'error'
|
||||||
|
// EventSource auto-reconnects unless CLOSED; nothing extra needed here
|
||||||
|
}
|
||||||
|
}, [endpoint, handleEvent])
|
||||||
|
|
||||||
|
const reconnect = useCallback(() => {
|
||||||
|
esRef.current?.close()
|
||||||
|
connect()
|
||||||
|
}, [connect])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect()
|
||||||
|
return () => {
|
||||||
|
esRef.current?.close()
|
||||||
|
connectionStateRef.current = 'disconnected'
|
||||||
|
}
|
||||||
|
}, [connect])
|
||||||
|
|
||||||
|
return {
|
||||||
|
get connectionState() {
|
||||||
|
return connectionStateRef.current
|
||||||
|
},
|
||||||
|
get lastEventTs() {
|
||||||
|
return lastEventTsRef.current
|
||||||
|
},
|
||||||
|
reconnect,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useCameraStore } from './useCameraStore'
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { CameraStatus } from '../types'
|
||||||
|
|
||||||
|
interface CameraState {
|
||||||
|
/** All cameras with latest status from SSE events */
|
||||||
|
cameras: Map<string, CameraStatus>
|
||||||
|
|
||||||
|
/** Replace the full camera list (e.g. on initial SSE or polling fallback) */
|
||||||
|
setCameras: (cameras: CameraStatus[]) => void
|
||||||
|
|
||||||
|
/** Upsert a single camera status from an SSE event */
|
||||||
|
updateCamera: (camera: CameraStatus) => void
|
||||||
|
|
||||||
|
/** Mark a camera offline */
|
||||||
|
setOffline: (cameraId: string) => void
|
||||||
|
|
||||||
|
/** Mark a camera online */
|
||||||
|
setOnline: (cameraId: string) => void
|
||||||
|
|
||||||
|
/** Update recording state from a recording_event */
|
||||||
|
setRecording: (cameraId: string, recording: boolean) => void
|
||||||
|
|
||||||
|
/** Derived: camera array (for UI consumption) */
|
||||||
|
getCameras: () => CameraStatus[]
|
||||||
|
|
||||||
|
/** Derived: online count */
|
||||||
|
getOnlineCount: () => number
|
||||||
|
|
||||||
|
/** Derived: recording count */
|
||||||
|
getRecordingCount: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCameraStore = create<CameraState>((set, get) => ({
|
||||||
|
cameras: new Map(),
|
||||||
|
|
||||||
|
setCameras: (list) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.cameras)
|
||||||
|
for (const cam of list) {
|
||||||
|
next.set(cam.camera_id, cam)
|
||||||
|
}
|
||||||
|
return { cameras: next }
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateCamera: (camera) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.cameras)
|
||||||
|
next.set(camera.camera_id, camera)
|
||||||
|
return { cameras: next }
|
||||||
|
}),
|
||||||
|
|
||||||
|
setOffline: (cameraId) =>
|
||||||
|
set((state) => {
|
||||||
|
const cam = state.cameras.get(cameraId)
|
||||||
|
if (!cam) return state
|
||||||
|
const next = new Map(state.cameras)
|
||||||
|
next.set(cameraId, { ...cam, online: false })
|
||||||
|
return { cameras: next }
|
||||||
|
}),
|
||||||
|
|
||||||
|
setOnline: (cameraId) =>
|
||||||
|
set((state) => {
|
||||||
|
const cam = state.cameras.get(cameraId)
|
||||||
|
if (!cam) return state
|
||||||
|
const next = new Map(state.cameras)
|
||||||
|
next.set(cameraId, { ...cam, online: true })
|
||||||
|
return { cameras: next }
|
||||||
|
}),
|
||||||
|
|
||||||
|
setRecording: (cameraId, recording) =>
|
||||||
|
set((state) => {
|
||||||
|
const cam = state.cameras.get(cameraId)
|
||||||
|
if (!cam) return state
|
||||||
|
const next = new Map(state.cameras)
|
||||||
|
next.set(cameraId, { ...cam, recording })
|
||||||
|
return { cameras: next }
|
||||||
|
}),
|
||||||
|
|
||||||
|
getCameras: () => Array.from(get().cameras.values()),
|
||||||
|
|
||||||
|
getOnlineCount: () =>
|
||||||
|
Array.from(get().cameras.values()).filter((c) => c.online).length,
|
||||||
|
|
||||||
|
getRecordingCount: () =>
|
||||||
|
Array.from(get().cameras.values()).filter((c) => c.recording).length,
|
||||||
|
}))
|
||||||
@@ -1,5 +1,26 @@
|
|||||||
// RemoteRig TypeScript types
|
// RemoteRig TypeScript types
|
||||||
|
|
||||||
|
/** Full camera status from GET /api/v1/cameras and SSE events */
|
||||||
|
export interface CameraStatus {
|
||||||
|
camera_id: string
|
||||||
|
friendly_name: string
|
||||||
|
battery_pct: number | null
|
||||||
|
video_remaining_sec: number | null
|
||||||
|
recording: boolean
|
||||||
|
mode: string
|
||||||
|
resolution: string
|
||||||
|
fps: number
|
||||||
|
online: boolean
|
||||||
|
last_seen: string // ISO 8601
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSE event envelope from /api/v1/events/stream */
|
||||||
|
export interface SSEEvent {
|
||||||
|
type: string
|
||||||
|
ts: string
|
||||||
|
payload?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export interface Camera {
|
export interface Camera {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user