first commit
This commit is contained in:
728
ui/components/devProfiler.tsx
Normal file
728
ui/components/devProfiler.tsx
Normal file
@@ -0,0 +1,728 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user