perf: bound SWR cache, clean stale storage, cap annotations
Add LRU cache provider (200 entries) to prevent unbounded SWR memory growth. Clean up stale PKCE localStorage keys on app init. Cap chart annotations to 20 visible reference lines with overflow indicator.
This commit is contained in:
@@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
- **More resilient under Redis outages.** If the caching layer goes down temporarily, Pulse now continues enforcing rate limits using an in-memory fallback instead of letting all traffic through unchecked. This prevents one infrastructure hiccup from snowballing into a bigger problem.
|
||||
- **Better handling of traffic bursts.** The system can now absorb 5x larger spikes of incoming events before applying backpressure. When events are dropped during extreme bursts, the system now tracks and logs exactly how many — so we can detect and respond to sustained overload before it affects your data.
|
||||
- **Faster map and globe loading.** The interactive 3D globe and dotted map in the Locations panel now only load when you scroll down to them, instead of rendering immediately on page load. This makes the initial dashboard load faster and saves battery on mobile devices.
|
||||
- **Real-time updates work across all servers.** If Pulse runs on multiple servers behind a load balancer, real-time visitor updates now stay in sync no matter which server you're connected to. Previously, you might miss live visitor changes if your connection landed on a different server than the one fetching data.
|
||||
- **Lighter memory usage in long sessions.** If you manage many sites and keep Pulse open for hours, the app now automatically clears out old cached data for sites you're no longer viewing. This keeps the tab responsive and prevents it from slowly using more and more memory over time.
|
||||
- **Cleaner login storage.** Temporary data left behind by abandoned sign-in attempts is now cleaned up automatically when the app loads. This prevents clutter from building up in your browser's storage over time.
|
||||
- **Tidier annotation display.** If you've added a lot of annotations to your chart, only the 20 most recent are shown as lines on the chart to keep it readable. A "+N more" label lets you know there are additional annotations.
|
||||
- **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.
|
||||
- **More reliable under heavy load.** Database queries now have automatic time limits so a single slow query can never lock up the system. If your dashboard or stats take too long to load, the request is gracefully cancelled instead of hanging forever — keeping everything responsive even during traffic spikes.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ThemeProviders, Toaster } from '@ciphera-net/ui'
|
||||
import { AuthProvider } from '@/lib/auth/context'
|
||||
import SWRProvider from '@/components/SWRProvider'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Plus_Jakarta_Sans } from 'next/font/google'
|
||||
import LayoutContent from './layout-content'
|
||||
@@ -46,12 +47,14 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" className={plusJakartaSans.variable} suppressHydrationWarning>
|
||||
<body className="antialiased min-h-screen flex flex-col bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-50">
|
||||
<ThemeProviders>
|
||||
<AuthProvider>
|
||||
<LayoutContent>{children}</LayoutContent>
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</ThemeProviders>
|
||||
<SWRProvider>
|
||||
<ThemeProviders>
|
||||
<AuthProvider>
|
||||
<LayoutContent>{children}</LayoutContent>
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</ThemeProviders>
|
||||
</SWRProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
12
components/SWRProvider.tsx
Normal file
12
components/SWRProvider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { SWRConfig } from 'swr'
|
||||
import { boundedCacheProvider } from '@/lib/swr/cache-provider'
|
||||
|
||||
export default function SWRProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SWRConfig value={{ provider: boundedCacheProvider }}>
|
||||
{children}
|
||||
</SWRConfig>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ const ANNOTATION_COLORS: Record<string, string> = {
|
||||
other: '#a3a3a3',
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_ANNOTATIONS = 20
|
||||
|
||||
const ANNOTATION_LABELS: Record<string, string> = {
|
||||
deploy: 'Deploy',
|
||||
campaign: 'Campaign',
|
||||
@@ -254,6 +256,9 @@ export default function Chart({
|
||||
return markers
|
||||
}, [annotations, chartData])
|
||||
|
||||
const visibleAnnotationMarkers = annotationMarkers.slice(0, MAX_VISIBLE_ANNOTATIONS)
|
||||
const hiddenAnnotationCount = Math.max(0, annotationMarkers.length - MAX_VISIBLE_ANNOTATIONS)
|
||||
|
||||
// ─── Right-click handler ──────────────────────────────────────────
|
||||
const handleChartContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!canManageAnnotations) return
|
||||
@@ -490,7 +495,7 @@ export default function Chart({
|
||||
/>
|
||||
|
||||
{/* Annotation reference lines */}
|
||||
{annotationMarkers.map((marker) => {
|
||||
{visibleAnnotationMarkers.map((marker) => {
|
||||
const primaryCategory = marker.annotations[0].category
|
||||
const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other
|
||||
return (
|
||||
@@ -534,7 +539,7 @@ export default function Chart({
|
||||
{annotationMarkers.length > 0 && (
|
||||
<>
|
||||
<span className="text-[10px] font-medium text-neutral-400 dark:text-neutral-500 mr-1">Annotations:</span>
|
||||
{annotationMarkers.map((marker) => {
|
||||
{visibleAnnotationMarkers.map((marker) => {
|
||||
const primary = marker.annotations[0]
|
||||
const color = ANNOTATION_COLORS[primary.category] || ANNOTATION_COLORS.other
|
||||
const count = marker.annotations.length
|
||||
@@ -577,6 +582,11 @@ export default function Chart({
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{hiddenAnnotationCount > 0 && (
|
||||
<span className="text-[10px] text-neutral-400 dark:text-neutral-500 ml-1">
|
||||
+{hiddenAnnotationCount} more
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-n
|
||||
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth'
|
||||
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { cleanupStaleStorage } from '@/lib/utils/storage-cleanup'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
@@ -131,6 +132,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
cleanupStaleStorage()
|
||||
|
||||
// * 1. Check server-side session (cookies)
|
||||
let session: Awaited<ReturnType<typeof getSessionAction>> = null
|
||||
try {
|
||||
|
||||
42
lib/swr/cache-provider.ts
Normal file
42
lib/swr/cache-provider.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// * Bounded LRU cache provider for SWR
|
||||
// * Prevents unbounded memory growth during long sessions across many sites
|
||||
|
||||
const MAX_CACHE_ENTRIES = 200
|
||||
|
||||
export function boundedCacheProvider() {
|
||||
const map = new Map()
|
||||
const accessOrder: string[] = []
|
||||
|
||||
const touch = (key: string) => {
|
||||
const idx = accessOrder.indexOf(key)
|
||||
if (idx > -1) accessOrder.splice(idx, 1)
|
||||
accessOrder.push(key)
|
||||
}
|
||||
|
||||
const evict = () => {
|
||||
while (map.size > MAX_CACHE_ENTRIES && accessOrder.length > 0) {
|
||||
const oldest = accessOrder.shift()!
|
||||
map.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get(key: string) {
|
||||
if (map.has(key)) touch(key)
|
||||
return map.get(key)
|
||||
},
|
||||
set(key: string, value: any) {
|
||||
map.set(key, value)
|
||||
touch(key)
|
||||
evict()
|
||||
},
|
||||
delete(key: string) {
|
||||
map.delete(key)
|
||||
const idx = accessOrder.indexOf(key)
|
||||
if (idx > -1) accessOrder.splice(idx, 1)
|
||||
},
|
||||
keys() {
|
||||
return map.keys()
|
||||
},
|
||||
}
|
||||
}
|
||||
17
lib/utils/storage-cleanup.ts
Normal file
17
lib/utils/storage-cleanup.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// * Cleans up stale localStorage entries on app initialization
|
||||
// * Prevents accumulation from abandoned OAuth flows
|
||||
|
||||
export function cleanupStaleStorage() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
// * PKCE keys are only needed during the OAuth callback
|
||||
// * If we're not on the callback page, they're stale leftovers
|
||||
if (!window.location.pathname.includes('/auth/callback')) {
|
||||
localStorage.removeItem('oauth_state')
|
||||
localStorage.removeItem('oauth_code_verifier')
|
||||
}
|
||||
} catch {
|
||||
// * Ignore errors (private browsing, storage disabled, etc.)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user