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": {
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
@@ -1395,7 +1396,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2071,7 +2072,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -3855,6 +3856,35 @@
|
||||
"funding": {
|
||||
"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": {
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export { useCameraStatus } from './useCameraStatus'
|
||||
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
|
||||
|
||||
/** 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 {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user