'use client' import { useGetDevGoroutinesQuery, useGetDevPprofQuery } from '@/lib/store' import type { GoroutineGroup } from '@/lib/store/apis/devApi' import { isDevelopmentMode } from '@/lib/utils/port' import { Activity, AlertTriangle, ChevronDown, ChevronRight, ChevronUp, Cpu, EyeOff, HardDrive, RotateCcw, TrendingUp, X } from 'lucide-react' import { formatBytes } from '@/lib/utils/strings' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts' // Format nanoseconds to human-readable string function formatNs (ns: number): string { if (ns < 1000) return `${ns}ns` if (ns < 1000000) return `${(ns / 1000).toFixed(1)}µs` if (ns < 1000000000) return `${(ns / 1000000).toFixed(1)}ms` return `${(ns / 1000000000).toFixed(2)}s` } // Format timestamp to HH:MM:SS function formatTime (timestamp: string): string { const date = new Date(timestamp) return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', }) } // Truncate function name for display function truncateFunction (fn: string): string { const parts = fn.split('/') const last = parts[parts.length - 1] if (last.length > 40) { return '...' + last.slice(-37) } return last } // Get category badge color function getCategoryColor (category: string): string { switch (category) { case 'per-request': return 'text-amber-400 bg-amber-400/10' case 'background': return 'text-blue-400 bg-blue-400/10' default: return 'text-zinc-400 bg-zinc-400/10' } } // Extract file path from stack (first line containing .go:) function getStackFilePath (stack: string[]): string { for (const line of stack) { // Match file path like "/path/to/file.go:123" and extract just the path const match = line.match(/^\s*([^\s]+\.go):\d+/) if (match) { return match[1] } } return '' } // Generate a stable ID for a goroutine group function getGoroutineId (g: GoroutineGroup): string { return `${g.top_func}::${g.state}::${g.count}::${g.wait_minutes ?? 0}` } // localStorage key for skipped goroutine file paths const SKIPPED_GOROUTINE_FILES_KEY = 'devProfiler.skippedGoroutineFiles' const PROFILER_VISIBLE_KEY = 'devProfiler.isVisible' const PROFILER_EXPANDED_KEY = 'devProfiler.isExpanded' // Load skipped goroutine file paths from localStorage function loadSkippedGoroutineFiles (): Set { if (typeof window === 'undefined') return new Set() try { const stored = localStorage.getItem(SKIPPED_GOROUTINE_FILES_KEY) return stored ? new Set(JSON.parse(stored)) : new Set() } catch { return new Set() } } // Save skipped goroutine file paths to localStorage function saveSkippedGoroutineFiles (skipped: Set): void { if (typeof window === 'undefined') return try { localStorage.setItem(SKIPPED_GOROUTINE_FILES_KEY, JSON.stringify([...skipped])) } catch { // Ignore storage errors } } function loadBooleanFromStorage (key: string, defaultValue: boolean): boolean { if (typeof window === 'undefined') return defaultValue try { const stored = localStorage.getItem(key) if (stored === null) return defaultValue if (stored === 'true') return true if (stored === 'false') return false return defaultValue } catch { return defaultValue } } function saveBooleanToStorage (key: string, value: boolean): void { if (typeof window === 'undefined') return try { localStorage.setItem(key, value ? 'true' : 'false') } catch { // Ignore storage errors } } // Goroutine Health Section subcomponent interface GoroutineHealthProps { goroutineData: { summary: { background: number per_request: number long_waiting: number potentially_stuck: number } total_goroutines: number } | undefined goroutineHealth: 'healthy' | 'warning' | 'critical' goroutineTrend: { isGrowing: boolean growthPercent: number avg: number } | null problemGoroutines: GoroutineGroup[] expandedGoroutines: Set toggleGoroutineExpand: (id: string) => void skippedGoroutines: Set onSkipGoroutine: (topFunc: string) => void onClearSkipped: () => void } function GoroutineHealthSection ({ goroutineData, goroutineHealth, goroutineTrend, problemGoroutines, expandedGoroutines, toggleGoroutineExpand, skippedGoroutines, onSkipGoroutine, onClearSkipped, }: GoroutineHealthProps): React.ReactNode { if (!goroutineData) return null const { summary, total_goroutines } = goroutineData return (
{/* Header with health status */}
Goroutine Health
{goroutineTrend?.isGrowing && ( +{goroutineTrend.growthPercent.toFixed(0)}% )} {goroutineHealth === 'critical' && ( Stuck )} {goroutineHealth === 'warning' && ( Long Wait )} {goroutineHealth === 'healthy' && ( Healthy )}
{/* Summary stats */}
Total {total_goroutines}
Background {summary.background}
Per-Request {summary.per_request}
Stuck 0 ? 'text-red-400' : 'text-zinc-500'}`}> {summary.potentially_stuck}
{/* Problem goroutines list */} {(problemGoroutines.length > 0 || skippedGoroutines.size > 0) && (
Potential Leaks {skippedGoroutines.size > 0 && ( )}
{problemGoroutines.map((g) => { const gid = getGoroutineId(g) return (
toggleGoroutineExpand(gid)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() toggleGoroutineExpand(gid) } }} className="flex w-full cursor-pointer flex-col gap-1 px-2 py-1.5 pr-8 text-left hover:bg-zinc-700/50" >
{expandedGoroutines.has(gid) ? ( ) : ( )} {truncateFunction(g.top_func)}
{g.category} {g.count}x {g.wait_minutes != null && ( {g.wait_minutes}m waiting )}
{expandedGoroutines.has(gid) && (
State: {g.state} {g.wait_reason && ( Wait: {g.wait_reason} )}
{g.stack.slice(0, 10).map((line, j) => (
{line}
))} {g.stack.length > 10 && (
... {g.stack.length - 10} more frames
)}
)}
)})} {problemGoroutines.length === 0 && skippedGoroutines.size > 0 && (
All potential leaks hidden
)} {problemGoroutines.length === 0 && skippedGoroutines.size === 0 && (summary.long_waiting > 0 || summary.potentially_stuck > 0) && (
{summary.long_waiting > 0 && summary.potentially_stuck > 0 ? `${summary.long_waiting} long-waiting and ${summary.potentially_stuck} stuck goroutines (background workers filtered)` : summary.long_waiting > 0 ? `${summary.long_waiting} long-waiting goroutines (background workers filtered)` : `${summary.potentially_stuck} stuck goroutines (background workers filtered)`}
)}
)} {/* No problems message */} {problemGoroutines.length === 0 && summary.long_waiting === 0 && summary.potentially_stuck === 0 && (
No goroutine leaks detected
)}
) } export function DevProfiler (): React.ReactNode { const [isVisible, setIsVisible] = useState(() => loadBooleanFromStorage(PROFILER_VISIBLE_KEY, true)) const [isExpanded, setIsExpanded] = useState(() => loadBooleanFromStorage(PROFILER_EXPANDED_KEY, true)) const [isDismissed, setIsDismissed] = useState(false) const [expandedGoroutines, setExpandedGoroutines] = useState>(new Set()) const [skippedGoroutines, setSkippedGoroutines] = useState>(() => loadSkippedGoroutineFiles()) // Sync skipped goroutines to localStorage useEffect(() => { saveSkippedGoroutineFiles(skippedGoroutines) }, [skippedGoroutines]) useEffect(() => { saveBooleanToStorage(PROFILER_VISIBLE_KEY, isVisible) }, [isVisible]) useEffect(() => { saveBooleanToStorage(PROFILER_EXPANDED_KEY, isExpanded) }, [isExpanded]) // Only fetch in development mode and when not dismissed const shouldFetch = isDevelopmentMode() && !isDismissed const { data, isLoading, error } = useGetDevPprofQuery(undefined, { pollingInterval: shouldFetch ? 10000 : 0, // Poll every 10 seconds skip: !shouldFetch, }) const { data: goroutineData } = useGetDevGoroutinesQuery(undefined, { pollingInterval: shouldFetch ? 10000 : 0, // Poll every 10 seconds skip: !shouldFetch, }) // Memoize chart data transformation const memoryChartData = useMemo(() => { if (!data?.history) return [] return data.history.map((point) => ({ time: formatTime(point.timestamp), alloc: point.alloc / (1024 * 1024), // Convert to MB heapInuse: point.heap_inuse / (1024 * 1024), })) }, [data?.history]) const cpuChartData = useMemo(() => { if (!data?.history) return [] return data.history.map((point) => ({ time: formatTime(point.timestamp), cpuPercent: point.cpu_percent, goroutines: point.goroutines, })) }, [data?.history]) // Detect goroutine count trend (growing = potential leak) const goroutineTrend = useMemo(() => { if (!data?.history || data.history.length < 5 || !data?.runtime) return null const recent = data.history.slice(-5) const avg = recent.reduce((sum, p) => sum + p.goroutines, 0) / recent.length const current = data.runtime.num_goroutine const isGrowing = current > avg * 1.1 // 10% above average const growthPercent = avg > 0 ? ((current - avg) / avg) * 100 : 0 return { isGrowing, growthPercent, avg } }, [data?.history, data?.runtime?.num_goroutine]) // Filter problem goroutines (stuck or long-waiting, excluding expected background workers and skipped) const problemGoroutines = useMemo(() => { if (!goroutineData?.groups) return [] return goroutineData.groups .filter((g) => { if (!g.wait_minutes || g.wait_minutes < 1) return false if (g.category === 'background') return false const filePath = getStackFilePath(g.stack) if (filePath && skippedGoroutines.has(filePath)) return false return true }) .slice(0, 5) }, [goroutineData?.groups, skippedGoroutines]) // Get goroutine health status const goroutineHealth = useMemo(() => { if (!goroutineData?.summary) return 'healthy' const { potentially_stuck, long_waiting } = goroutineData.summary if (potentially_stuck > 0) return 'critical' if (long_waiting > 0) return 'warning' return 'healthy' }, [goroutineData?.summary]) const handleDismiss = useCallback(() => { setIsDismissed(true) saveBooleanToStorage(PROFILER_VISIBLE_KEY, false) }, []) const toggleGoroutineExpand = useCallback((id: string) => { setExpandedGoroutines((prev) => { const next = new Set(prev) if (next.has(id)) { next.delete(id) } else { next.add(id) } return next }) }, []) const handleSkipGoroutine = useCallback((filePath: string) => { setSkippedGoroutines((prev) => { const next = new Set(prev) next.add(filePath) return next }) }, []) const handleClearSkipped = useCallback(() => { setSkippedGoroutines(new Set()) }, []) const handleToggleExpand = useCallback(() => { setIsExpanded((prev) => !prev) }, []) const handleToggleVisible = useCallback(() => { setIsVisible((prev) => !prev) }, []) // Don't render in production mode or if dismissed if (!isDevelopmentMode() || isDismissed) { return null } // Minimized state - just show a small button if (!isVisible) { return ( ) } return (
{/* Header */}
Dev Profiler {isLoading && ( )}
{Boolean(error) && (
Failed to load profiling data
)} {isExpanded && data && (
{/* Current Stats */}
CPU Usage {data.cpu.usage_percent.toFixed(1)}%
Heap Alloc {formatBytes(data.memory.alloc)}
Heap In-Use {formatBytes(data.memory.heap_inuse)}
System {formatBytes(data.memory.sys)}
Goroutines {data.runtime.num_goroutine}
GC Pause {formatNs(data.runtime.gc_pause_ns)}
{/* CPU Chart */}
CPU Usage (last 5 min)
`${Number(v).toFixed(0)}%`} width={35} domain={[0, 'auto']} />
CPU % Goroutines
{/* Memory Chart */}
Memory (last 5 min)
`${Number(v).toFixed(0)}MB`} width={45} />
Alloc Heap In-Use
{/* Top Allocations */}
Top Allocations
{(data.top_allocations ?? []).map((alloc, i) => (
{truncateFunction(alloc.function)} {alloc.file}:{alloc.line}
{formatBytes(alloc.bytes)} {alloc.count.toLocaleString()} allocs
))}
{/* Goroutine Health */} {/* Footer with info */}
CPUs: {data.runtime.num_cpu} | GOMAXPROCS: {data.runtime.gomaxprocs} | GC: {data.runtime.num_gc} | Objects: {data.memory.heap_objects.toLocaleString()}
)} {/* Collapsed state */} {!isExpanded && data && (
CPU: {data.cpu.usage_percent.toFixed(1)}% Heap: {formatBytes(data.memory.heap_inuse)} Goroutines: {data.runtime.num_goroutine}
)}
) }