728 lines
26 KiB
TypeScript
728 lines
26 KiB
TypeScript
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<string> {
|
|
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<string>): 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<string>;
|
|
toggleGoroutineExpand: (id: string) => void;
|
|
skippedGoroutines: Set<string>;
|
|
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 (
|
|
<div className="p-3">
|
|
{/* Header with health status */}
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Activity className="h-3 w-3 text-emerald-400" />
|
|
<span className="text-zinc-400">Goroutine Health</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{goroutineTrend?.isGrowing && (
|
|
<span className="flex items-center gap-1 text-amber-400" title="Goroutine count growing">
|
|
<TrendingUp className="h-3 w-3" />
|
|
<span className="text-[10px]">+{goroutineTrend.growthPercent.toFixed(0)}%</span>
|
|
</span>
|
|
)}
|
|
{goroutineHealth === "critical" && (
|
|
<span className="flex items-center gap-1 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
Stuck
|
|
</span>
|
|
)}
|
|
{goroutineHealth === "warning" && (
|
|
<span className="flex items-center gap-1 rounded bg-amber-500/20 px-1.5 py-0.5 text-[10px] text-amber-400">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
Long Wait
|
|
</span>
|
|
)}
|
|
{goroutineHealth === "healthy" && (
|
|
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] text-emerald-400">Healthy</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary stats */}
|
|
<div className="mb-2 grid grid-cols-4 gap-2 rounded bg-zinc-800/50 p-2">
|
|
<div className="flex flex-col items-center">
|
|
<span className="text-[10px] text-zinc-500">Total</span>
|
|
<span className="font-semibold text-emerald-400">{total_goroutines}</span>
|
|
</div>
|
|
<div className="flex flex-col items-center">
|
|
<span className="text-[10px] text-zinc-500">Background</span>
|
|
<span className="font-semibold text-blue-400">{summary.background}</span>
|
|
</div>
|
|
<div className="flex flex-col items-center">
|
|
<span className="text-[10px] text-zinc-500">Per-Request</span>
|
|
<span className="font-semibold text-amber-400">{summary.per_request}</span>
|
|
</div>
|
|
<div className="flex flex-col items-center">
|
|
<span className="text-[10px] text-zinc-500">Stuck</span>
|
|
<span className={`font-semibold ${summary.potentially_stuck > 0 ? "text-red-400" : "text-zinc-500"}`}>
|
|
{summary.potentially_stuck}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Problem goroutines list */}
|
|
{(problemGoroutines.length > 0 || skippedGoroutines.size > 0) && (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] text-zinc-500">Potential Leaks</span>
|
|
{skippedGoroutines.size > 0 && (
|
|
<button
|
|
onClick={onClearSkipped}
|
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
|
title="Clear all hidden goroutines"
|
|
>
|
|
<RotateCcw className="h-2.5 w-2.5" />
|
|
{skippedGoroutines.size} hidden
|
|
</button>
|
|
)}
|
|
</div>
|
|
{problemGoroutines.map((g) => {
|
|
const gid = getGoroutineId(g);
|
|
return (
|
|
<div key={gid} className="group relative rounded bg-zinc-800">
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => 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"
|
|
>
|
|
<div className="flex w-full items-center gap-2">
|
|
{expandedGoroutines.has(gid) ? (
|
|
<ChevronDown className="h-3 w-3 shrink-0 text-zinc-500" />
|
|
) : (
|
|
<ChevronRight className="h-3 w-3 shrink-0 text-zinc-500" />
|
|
)}
|
|
<span className="min-w-0 flex-1 break-all text-zinc-300" title={g.top_func}>
|
|
{truncateFunction(g.top_func)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 pl-5 text-[10px]">
|
|
<span className={`rounded px-1 py-0.5 ${getCategoryColor(g.category)}`}>{g.category}</span>
|
|
<span className="text-zinc-500">{g.count}x</span>
|
|
{g.wait_minutes != null && <span className="text-amber-400">{g.wait_minutes}m waiting</span>}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const filePath = getStackFilePath(g.stack);
|
|
if (filePath) onSkipGoroutine(filePath);
|
|
}}
|
|
className="absolute top-1.5 right-1 shrink-0 rounded p-1 text-zinc-500 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-zinc-600 hover:text-zinc-300"
|
|
title="Hide goroutines from this file"
|
|
>
|
|
<EyeOff className="h-3 w-3" />
|
|
</button>
|
|
{expandedGoroutines.has(gid) && (
|
|
<div className="border-t border-zinc-700 bg-zinc-900/50 px-2 py-1.5">
|
|
<div className="mb-1 text-[10px] text-zinc-500">
|
|
State: <span className="text-zinc-400">{g.state}</span>
|
|
{g.wait_reason && (
|
|
<span className="ml-2">
|
|
Wait: <span className="text-amber-400">{g.wait_reason}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="max-h-32 overflow-x-hidden overflow-y-auto">
|
|
{g.stack.slice(0, 10).map((line, j) => (
|
|
<div key={j} className="text-[9px] break-all text-zinc-500">
|
|
{line}
|
|
</div>
|
|
))}
|
|
{g.stack.length > 10 && <div className="text-[9px] text-zinc-600">... {g.stack.length - 10} more frames</div>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{problemGoroutines.length === 0 && skippedGoroutines.size > 0 && (
|
|
<div className="rounded bg-zinc-800/30 py-2 text-center text-[10px] text-zinc-500">All potential leaks hidden</div>
|
|
)}
|
|
{problemGoroutines.length === 0 &&
|
|
skippedGoroutines.size === 0 &&
|
|
(summary.long_waiting > 0 || summary.potentially_stuck > 0) && (
|
|
<div className="rounded bg-zinc-800/30 px-2 py-2 text-center text-[10px] text-zinc-500">
|
|
{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)`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* No problems message */}
|
|
{problemGoroutines.length === 0 && summary.long_waiting === 0 && summary.potentially_stuck === 0 && (
|
|
<div className="rounded bg-zinc-800/30 py-2 text-center text-[10px] text-zinc-500">No goroutine leaks detected</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DevProfiler(): React.ReactNode {
|
|
const [isVisible, setIsVisible] = useState<boolean>(() => loadBooleanFromStorage(PROFILER_VISIBLE_KEY, true));
|
|
const [isExpanded, setIsExpanded] = useState<boolean>(() => loadBooleanFromStorage(PROFILER_EXPANDED_KEY, true));
|
|
const [isDismissed, setIsDismissed] = useState(false);
|
|
const [expandedGoroutines, setExpandedGoroutines] = useState<Set<string>>(new Set());
|
|
const [skippedGoroutines, setSkippedGoroutines] = useState<Set<string>>(() => 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 (
|
|
<button
|
|
onClick={handleToggleVisible}
|
|
className="fixed right-4 bottom-4 z-50 flex h-10 w-10 items-center justify-center rounded-full bg-zinc-900 text-white shadow-lg transition-all hover:bg-zinc-800"
|
|
title="Show Dev Profiler"
|
|
>
|
|
<Activity className="h-5 w-5" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fixed right-4 bottom-4 z-50 w-[420px] overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 font-mono text-xs text-zinc-100 shadow-2xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-zinc-700 bg-zinc-800 px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-emerald-400">Dev Profiler</span>
|
|
{isLoading && <span className="ml-2 h-2 w-2 animate-pulse rounded-full bg-amber-400" />}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={handleToggleExpand}
|
|
className="rounded p-1 transition-colors hover:bg-zinc-700"
|
|
title={isExpanded ? "Collapse" : "Expand"}
|
|
>
|
|
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
|
</button>
|
|
<button onClick={handleToggleVisible} className="rounded p-1 transition-colors hover:bg-zinc-700" title="Minimize">
|
|
<ChevronDown className="h-4 w-4" />
|
|
</button>
|
|
<button onClick={handleDismiss} className="rounded p-1 transition-colors hover:bg-zinc-700" title="Dismiss">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{Boolean(error) && <div className="border-b border-zinc-700 bg-red-900/30 px-3 py-2 text-red-300">Failed to load profiling data</div>}
|
|
|
|
{isExpanded && data && (
|
|
<div className="custom-scrollbar max-h-[70vh] overflow-x-hidden overflow-y-auto">
|
|
{/* Current Stats */}
|
|
<div className="grid grid-cols-3 gap-2 border-b border-zinc-700 p-3">
|
|
<div className="flex flex-col">
|
|
<span className="text-zinc-500">CPU Usage</span>
|
|
<span className="font-semibold text-orange-400">{data.cpu.usage_percent.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-zinc-500">Heap Alloc</span>
|
|
<span className="font-semibold text-cyan-400">{formatBytes(data.memory.alloc)}</span>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-zinc-500">Heap In-Use</span>
|
|
<span className="font-semibold text-blue-400">{formatBytes(data.memory.heap_inuse)}</span>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-zinc-500">System</span>
|
|
<span className="font-semibold text-purple-400">{formatBytes(data.memory.sys)}</span>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-zinc-500">Goroutines</span>
|
|
<span className="font-semibold text-emerald-400">{data.runtime.num_goroutine}</span>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-zinc-500">GC Pause</span>
|
|
<span className="font-semibold text-amber-400">{formatNs(data.runtime.gc_pause_ns)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* CPU Chart */}
|
|
<div className="border-b border-zinc-700 p-3">
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<Cpu className="h-3 w-3 text-orange-400" />
|
|
<span className="text-zinc-400">CPU Usage (last 5 min)</span>
|
|
</div>
|
|
<div className="h-24">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={cpuChartData}>
|
|
<defs>
|
|
<linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#f97316" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#f97316" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="goroutineGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#34d399" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#34d399" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#3f3f46" />
|
|
<XAxis dataKey="time" tick={{ fill: "#71717a", fontSize: 9 }} tickLine={false} axisLine={false} />
|
|
<YAxis
|
|
yAxisId="left"
|
|
tick={{ fill: "#71717a", fontSize: 9 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(v) => `${Number(v).toFixed(0)}%`}
|
|
width={35}
|
|
domain={[0, "auto"]}
|
|
/>
|
|
<YAxis
|
|
yAxisId="right"
|
|
orientation="right"
|
|
tick={{ fill: "#71717a", fontSize: 9 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
width={30}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "#18181b",
|
|
border: "1px solid #3f3f46",
|
|
borderRadius: "6px",
|
|
fontSize: "10px",
|
|
}}
|
|
labelStyle={{ color: "#a1a1aa" }}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="cpuPercent"
|
|
stroke="#f97316"
|
|
strokeWidth={1.5}
|
|
fill="url(#cpuGradient)"
|
|
yAxisId="left"
|
|
name="CPU %"
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="goroutines"
|
|
stroke="#34d399"
|
|
strokeWidth={1.5}
|
|
fill="url(#goroutineGradient)"
|
|
yAxisId="right"
|
|
name="Goroutines"
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
<div className="mt-1 flex gap-4 text-[10px]">
|
|
<span className="flex items-center gap-1">
|
|
<span className="h-2 w-2 rounded-full bg-orange-500" />
|
|
CPU %
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
|
Goroutines
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Memory Chart */}
|
|
<div className="border-b border-zinc-700 p-3">
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<HardDrive className="h-3 w-3 text-cyan-400" />
|
|
<span className="text-zinc-400">Memory (last 5 min)</span>
|
|
</div>
|
|
<div className="h-24">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={memoryChartData}>
|
|
<defs>
|
|
<linearGradient id="allocGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#22d3ee" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#22d3ee" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="heapGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#3f3f46" />
|
|
<XAxis dataKey="time" tick={{ fill: "#71717a", fontSize: 9 }} tickLine={false} axisLine={false} />
|
|
<YAxis
|
|
tick={{ fill: "#71717a", fontSize: 9 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(v) => `${Number(v).toFixed(0)}MB`}
|
|
width={45}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "#18181b",
|
|
border: "1px solid #3f3f46",
|
|
borderRadius: "6px",
|
|
fontSize: "10px",
|
|
}}
|
|
labelStyle={{ color: "#a1a1aa" }}
|
|
/>
|
|
<Area type="monotone" dataKey="alloc" stroke="#22d3ee" strokeWidth={1.5} fill="url(#allocGradient)" name="Alloc" />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="heapInuse"
|
|
stroke="#3b82f6"
|
|
strokeWidth={1.5}
|
|
fill="url(#heapGradient)"
|
|
name="Heap In-Use"
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
<div className="mt-1 flex gap-4 text-[10px]">
|
|
<span className="flex items-center gap-1">
|
|
<span className="h-2 w-2 rounded-full bg-cyan-400" />
|
|
Alloc
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
|
Heap In-Use
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top Allocations */}
|
|
<div className="border-b border-zinc-700 p-3">
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<HardDrive className="h-3 w-3 text-rose-400" />
|
|
<span className="text-zinc-400">Top Allocations</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{(data.top_allocations ?? []).map((alloc, i) => (
|
|
<div key={i} className="flex items-center justify-between rounded bg-zinc-800 px-2 py-1">
|
|
<div className="flex flex-col overflow-hidden">
|
|
<span className="truncate text-zinc-300" title={alloc.function}>
|
|
{truncateFunction(alloc.function)}
|
|
</span>
|
|
<span className="text-[10px] text-zinc-500">
|
|
{alloc.file}:{alloc.line}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col items-end">
|
|
<span className="text-rose-400">{formatBytes(alloc.bytes)}</span>
|
|
<span className="text-[10px] text-zinc-500">{alloc.count.toLocaleString()} allocs</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Goroutine Health */}
|
|
<GoroutineHealthSection
|
|
goroutineData={goroutineData}
|
|
goroutineHealth={goroutineHealth}
|
|
goroutineTrend={goroutineTrend}
|
|
problemGoroutines={problemGoroutines}
|
|
expandedGoroutines={expandedGoroutines}
|
|
toggleGoroutineExpand={toggleGoroutineExpand}
|
|
skippedGoroutines={skippedGoroutines}
|
|
onSkipGoroutine={handleSkipGoroutine}
|
|
onClearSkipped={handleClearSkipped}
|
|
/>
|
|
|
|
{/* Footer with info */}
|
|
<div className="border-t border-zinc-700 bg-zinc-800 px-3 py-2 text-[10px] text-zinc-500">
|
|
CPUs: {data.runtime.num_cpu} | GOMAXPROCS: {data.runtime.gomaxprocs} | GC: {data.runtime.num_gc} | Objects:{" "}
|
|
{data.memory.heap_objects.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Collapsed state */}
|
|
{!isExpanded && data && (
|
|
<div className="flex items-center justify-between bg-zinc-800/50 px-3 py-2">
|
|
<span className="text-orange-400">CPU: {data.cpu.usage_percent.toFixed(1)}%</span>
|
|
<span className="text-zinc-400">Heap: {formatBytes(data.memory.heap_inuse)}</span>
|
|
<span className="text-zinc-400">Goroutines: {data.runtime.num_goroutine}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |