perf: replace real-time polling with SSE streaming
Replace 5-second setInterval polling with EventSource connection to the new /realtime/stream SSE endpoint. The server pushes visitor updates instead of each client independently polling. Auto-reconnects on connection drops.
This commit is contained in:
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
### Improved
|
||||
|
||||
- **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time.
|
||||
- **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time.
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { getSite, type Site } from '@/lib/api/sites'
|
||||
import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
|
||||
import { getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
|
||||
import { useRealtimeSSE } from '@/lib/hooks/useRealtimeSSE'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
import { UserIcon } from '@ciphera-net/ui'
|
||||
@@ -27,28 +28,20 @@ export default function RealtimePage() {
|
||||
const siteId = params.id as string
|
||||
|
||||
const [site, setSite] = useState<Site | null>(null)
|
||||
const [visitors, setVisitors] = useState<Visitor[]>([])
|
||||
const { visitors } = useRealtimeSSE(siteId)
|
||||
const [selectedVisitor, setSelectedVisitor] = useState<Visitor | null>(null)
|
||||
const [sessionEvents, setSessionEvents] = useState<SessionEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingEvents, setLoadingEvents] = useState(false)
|
||||
|
||||
// Load site info and initial visitors
|
||||
// Load site info
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const [siteData, visitorsData] = await Promise.all([
|
||||
getSite(siteId),
|
||||
getRealtimeVisitors(siteId)
|
||||
])
|
||||
const siteData = await getSite(siteId)
|
||||
setSite(siteData)
|
||||
setVisitors(visitorsData || [])
|
||||
// Select first visitor if available
|
||||
if (visitorsData && visitorsData.length > 0) {
|
||||
handleSelectVisitor(visitorsData[0])
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load realtime visitors')
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load site')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -56,27 +49,12 @@ export default function RealtimePage() {
|
||||
init()
|
||||
}, [siteId])
|
||||
|
||||
// Poll for updates
|
||||
// Auto-select the first visitor when the list populates and nothing is selected
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const data = await getRealtimeVisitors(siteId)
|
||||
setVisitors(data || [])
|
||||
|
||||
// Update selected visitor reference if they are still in the list
|
||||
if (selectedVisitor) {
|
||||
const updatedVisitor = data?.find(v => v.session_id === selectedVisitor.session_id)
|
||||
if (updatedVisitor) {
|
||||
// Don't overwrite the selectedVisitor state directly to avoid flickering details
|
||||
// But we could update "last seen" indicators if we wanted
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [siteId, selectedVisitor])
|
||||
if (visitors.length > 0 && !selectedVisitor) {
|
||||
handleSelectVisitor(visitors[0])
|
||||
}
|
||||
}, [visitors]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSelectVisitor = async (visitor: Visitor) => {
|
||||
setSelectedVisitor(visitor)
|
||||
|
||||
53
lib/hooks/useRealtimeSSE.ts
Normal file
53
lib/hooks/useRealtimeSSE.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// * SSE hook for real-time visitor streaming.
|
||||
// * Replaces 5-second polling with a persistent EventSource connection.
|
||||
// * The backend broadcasts one DB query per site to all connected clients,
|
||||
// * so 1,000 users on the same site share a single query instead of each
|
||||
// * triggering their own.
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { API_URL } from '@/lib/api/client'
|
||||
import type { Visitor } from '@/lib/api/realtime'
|
||||
|
||||
interface UseRealtimeSSEReturn {
|
||||
visitors: Visitor[]
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export function useRealtimeSSE(siteId: string): UseRealtimeSSEReturn {
|
||||
const [visitors, setVisitors] = useState<Visitor[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const esRef = useRef<EventSource | null>(null)
|
||||
|
||||
// Stable callback so we don't recreate EventSource on every render
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
setVisitors(data.visitors || [])
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteId) return
|
||||
|
||||
const url = `${API_URL}/api/v1/sites/${siteId}/realtime/stream`
|
||||
const es = new EventSource(url, { withCredentials: true })
|
||||
esRef.current = es
|
||||
|
||||
es.onopen = () => setConnected(true)
|
||||
es.onmessage = handleMessage
|
||||
es.onerror = () => {
|
||||
setConnected(false)
|
||||
// EventSource auto-reconnects with exponential backoff
|
||||
}
|
||||
|
||||
return () => {
|
||||
es.close()
|
||||
esRef.current = null
|
||||
setConnected(false)
|
||||
}
|
||||
}, [siteId, handleMessage])
|
||||
|
||||
return { visitors, connected }
|
||||
}
|
||||
Reference in New Issue
Block a user