CUB-195: add useSSE hook and Zustand camera store for live SSE events

This commit is contained in:
rex-bot
2026-05-20 22:56:46 +00:00
parent 5bbfc11597
commit fa0956c6fd
7 changed files with 303 additions and 4 deletions
+2
View File
@@ -1,2 +1,4 @@
export { useCameraStatus } from './useCameraStatus'
export { useSystemHealth } from './useSystemHealth'
export { useSSE } from './useSSE'
export type { SSEConnectionState } from './useSSE'
+158
View File
@@ -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,
}
}
+1
View File
@@ -0,0 +1 @@
export { useCameraStore } from './useCameraStore'
+86
View File
@@ -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,
}))
+21
View File
@@ -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