| import { useMemo, useCallback } from "react"; |
| import { |
| AreaChart, |
| Area, |
| XAxis, |
| YAxis, |
| Tooltip, |
| CartesianGrid, |
| ResponsiveContainer, |
| } from "recharts"; |
| import type { |
| TabDataPoint, |
| MemoryDataPoint, |
| ServerDataPoint, |
| } from "../../stores/useAppStore"; |
|
|
| interface Props { |
| data: TabDataPoint[]; |
| memoryData?: MemoryDataPoint[]; |
| serverData?: ServerDataPoint[]; |
| instances: { id: string; profileName: string }[]; |
| selectedInstanceId: string | null; |
| onSelectInstance: (id: string) => void; |
| } |
|
|
| const COLORS = [ |
| "#6366f1", |
| "#8b5cf6", |
| "#06b6d4", |
| "#10b981", |
| "#f59e0b", |
| "#ef4444", |
| "#ec4899", |
| "#14b8a6", |
| ]; |
|
|
| function formatTime(timestamp: number): string { |
| return new Date(timestamp).toLocaleTimeString("en-GB", { |
| hour: "2-digit", |
| minute: "2-digit", |
| }); |
| } |
|
|
| function formatMetricValue(value: number, maximumFractionDigits = 0): string { |
| if (!Number.isFinite(value)) return "0"; |
| return new Intl.NumberFormat("en-US", { maximumFractionDigits }).format( |
| value, |
| ); |
| } |
|
|
| |
| function GlassTooltip({ |
| active, |
| payload, |
| label, |
| instances, |
| }: { |
| active?: boolean; |
| payload?: readonly { dataKey: string; value: number; color: string }[]; |
| label?: string | number; |
| instances: { id: string; profileName: string }[]; |
| }) { |
| if (!active || !payload?.length) return null; |
|
|
| return ( |
| <div |
| style={{ |
| background: "rgba(15, 17, 23, 0.75)", |
| backdropFilter: "blur(16px)", |
| WebkitBackdropFilter: "blur(16px)", |
| border: "1px solid rgba(255,255,255,0.12)", |
| borderRadius: "14px", |
| padding: "10px 14px", |
| boxShadow: |
| "0 20px 40px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)", |
| minWidth: "140px", |
| }} |
| > |
| <div |
| style={{ |
| fontSize: "10px", |
| color: "#94a3b8", |
| marginBottom: "6px", |
| fontWeight: 600, |
| letterSpacing: "0.05em", |
| textTransform: "uppercase" as const, |
| }} |
| > |
| {formatTime(label as number)} |
| </div> |
| {payload.map((entry) => { |
| const nameStr = String(entry.dataKey); |
| const isMemory = nameStr.endsWith("_mem"); |
| const isHeap = nameStr === "goHeapMB"; |
| const instId = isMemory ? nameStr.replace("_mem", "") : nameStr; |
| const inst = instances.find((i) => i.id === instId); |
| const displayName = isHeap |
| ? "Server Heap" |
| : inst?.profileName || instId; |
| const suffix = isMemory ? " (mem)" : isHeap ? "" : " (tabs)"; |
| const formattedVal = |
| isMemory || isHeap |
| ? `${formatMetricValue(Number(entry.value), 1)}MB` |
| : formatMetricValue(Number(entry.value)); |
| |
| return ( |
| <div |
| key={entry.dataKey} |
| style={{ |
| display: "flex", |
| alignItems: "center", |
| gap: "8px", |
| padding: "2px 0", |
| fontSize: "12px", |
| }} |
| > |
| <span |
| style={{ |
| width: "8px", |
| height: "8px", |
| borderRadius: "50%", |
| background: entry.color, |
| boxShadow: `0 0 6px ${entry.color}80`, |
| flexShrink: 0, |
| }} |
| /> |
| <span style={{ color: "#cbd5e1", flex: 1 }}> |
| {displayName} |
| {suffix} |
| </span> |
| <span |
| style={{ |
| color: "#f1f5f9", |
| fontWeight: 600, |
| fontFamily: "var(--font-mono, monospace)", |
| }} |
| > |
| {formattedVal} |
| </span> |
| </div> |
| ); |
| })} |
| </div> |
| ); |
| } |
|
|
| |
| function AnimatedCursor({ |
| points, |
| height, |
| }: { |
| points?: Array<{ x: number; y: number }>; |
| width?: number; |
| height?: number; |
| }) { |
| if (!points?.length || !height) return null; |
| const x = points[0].x; |
| return ( |
| <line |
| x1={x} |
| y1={0} |
| x2={x} |
| y2={height} |
| stroke="rgba(99,102,241,0.4)" |
| strokeWidth={1} |
| strokeDasharray="4 3" |
| style={{ transition: "x1 80ms ease, x2 80ms ease" }} |
| /> |
| ); |
| } |
|
|
| |
| function LoadingDots({ text }: { text: string }) { |
| return ( |
| <div className="dashboard-panel flex h-56 flex-col items-center justify-center gap-3"> |
| <div className="flex gap-1.5"> |
| {[0, 1, 2].map((i) => ( |
| <span |
| key={i} |
| className="inline-block h-2 w-2 rounded-full bg-indigo-500/70" |
| style={{ |
| animation: "pulse 1.4s ease-in-out infinite", |
| animationDelay: `${i * 0.2}s`, |
| }} |
| /> |
| ))} |
| </div> |
| <span className="text-sm text-text-muted">{text}</span> |
| </div> |
| ); |
| } |
|
|
| |
| export default function TabsChart({ |
| data, |
| memoryData, |
| serverData, |
| instances, |
| selectedInstanceId, |
| onSelectInstance, |
| }: Props) { |
| const instanceColors = useMemo(() => { |
| const colors: Record<string, string> = {}; |
| instances.forEach((inst, i) => { |
| colors[inst.id] = COLORS[i % COLORS.length]; |
| }); |
| return colors; |
| }, [instances]); |
|
|
| const mergedData = useMemo(() => { |
| const memByTime = new Map((memoryData || []).map((m) => [m.timestamp, m])); |
| const serverByTime = new Map( |
| (serverData || []).map((s) => [s.timestamp, s]), |
| ); |
| const baseData = |
| data.length > 0 |
| ? data |
| : (serverData || []).map((s) => ({ timestamp: s.timestamp })); |
|
|
| return baseData.map((d) => { |
| const merged: Record<string, number> = { timestamp: d.timestamp }; |
| for (const [key, val] of Object.entries(d)) { |
| if (key !== "timestamp") merged[key] = val as number; |
| } |
| const mem = memByTime.get(d.timestamp); |
| if (mem) { |
| for (const [key, val] of Object.entries(mem)) { |
| if (key !== "timestamp") merged[`${key}_mem`] = val; |
| } |
| } |
| const srv = serverByTime.get(d.timestamp); |
| if (srv) merged.goHeapMB = srv.goHeapMB; |
| return merged; |
| }); |
| }, [data, memoryData, serverData]); |
|
|
| |
| const currentValues = useMemo(() => { |
| if (mergedData.length === 0) return null; |
| const latest = mergedData[mergedData.length - 1]; |
| const vals: Array<{ label: string; value: string; color: string }> = []; |
| instances.forEach((inst) => { |
| const v = latest[inst.id]; |
| if (v !== undefined) { |
| vals.push({ |
| label: inst.profileName, |
| value: `${formatMetricValue(Number(v))} tabs`, |
| color: instanceColors[inst.id] || COLORS[0], |
| }); |
| } |
| const memV = latest[`${inst.id}_mem`]; |
| if (memV !== undefined) { |
| vals.push({ |
| label: `${inst.profileName} mem`, |
| value: `${formatMetricValue(Number(memV), 1)}MB`, |
| color: instanceColors[inst.id] || COLORS[0], |
| }); |
| } |
| }); |
| if (latest.goHeapMB !== undefined) { |
| vals.push({ |
| label: "Heap", |
| value: `${formatMetricValue(Number(latest.goHeapMB), 1)}MB`, |
| color: "#94a3b8", |
| }); |
| } |
| return vals; |
| }, [mergedData, instances, instanceColors]); |
|
|
| |
| const tooltipContent = useCallback( |
| (props: any) => <GlassTooltip {...props} instances={instances} />, |
| [instances], |
| ); |
|
|
| if (mergedData.length < 2) { |
| return ( |
| <LoadingDots |
| text={ |
| mergedData.length === 0 |
| ? "Collecting data..." |
| : "Waiting for more data..." |
| } |
| /> |
| ); |
| } |
|
|
| const hasMemory = memoryData && memoryData.length > 0; |
| const hasServer = serverData && serverData.length > 0; |
|
|
| return ( |
| <div className="dashboard-panel overflow-hidden"> |
| <div className="flex items-center justify-between border-b border-border-subtle px-4 py-3"> |
| <div className="flex items-center gap-2.5"> |
| <div> |
| <div className="dashboard-section-label">Monitoring</div> |
| <div className="mt-1 flex items-center gap-2 text-sm font-semibold text-text-primary"> |
| Live telemetry |
| {/* Pulse indicator */} |
| <span className="relative flex h-2.5 w-2.5"> |
| <span |
| className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" |
| style={{ |
| animation: "ping 1.5s cubic-bezier(0,0,0.2,1) infinite", |
| }} |
| /> |
| <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" /> |
| </span> |
| </div> |
| </div> |
| </div> |
| <div className="flex flex-wrap items-center gap-1.5"> |
| {/* Current value badges */} |
| {currentValues?.map((cv) => ( |
| <span |
| key={cv.label} |
| className="flex items-center gap-1.5 rounded-md border border-white/[0.08] bg-white/[0.04] px-2 py-1 text-[0.65rem] font-medium text-text-secondary" |
| > |
| <span |
| className="h-1.5 w-1.5 rounded-full" |
| style={{ |
| background: cv.color, |
| boxShadow: `0 0 4px ${cv.color}60`, |
| }} |
| /> |
| {cv.value} |
| </span> |
| ))} |
| {/* Metric type badges */} |
| <span className="rounded-sm border border-border-subtle bg-white/[0.03] px-2 py-1 text-[0.68rem] font-semibold uppercase tracking-[0.08em] text-text-secondary"> |
| Tabs |
| </span> |
| {hasMemory && ( |
| <span className="rounded-sm border border-info/35 bg-info/10 px-2 py-1 text-[0.68rem] font-semibold uppercase tracking-[0.08em] text-info"> |
| Memory |
| </span> |
| )} |
| {hasServer && ( |
| <span className="rounded-sm border border-primary/35 bg-primary/10 px-2 py-1 text-[0.68rem] font-semibold uppercase tracking-[0.08em] text-primary"> |
| Heap |
| </span> |
| )} |
| </div> |
| </div> |
| <div className="px-2 py-3"> |
| <ResponsiveContainer width="100%" height={220}> |
| <AreaChart |
| data={mergedData} |
| margin={{ |
| top: 16, |
| right: hasMemory || hasServer ? 50 : 16, |
| bottom: 8, |
| left: 8, |
| }} |
| > |
| <defs> |
| {/* Gradients for each instance */} |
| {instances.map((inst) => { |
| const color = instanceColors[inst.id] || COLORS[0]; |
| return ( |
| <linearGradient |
| key={`grad-${inst.id}`} |
| id={`grad-${inst.id}`} |
| x1="0" |
| y1="0" |
| x2="0" |
| y2="1" |
| > |
| <stop offset="0%" stopColor={color} stopOpacity={0.35} /> |
| <stop offset="95%" stopColor={color} stopOpacity={0.02} /> |
| </linearGradient> |
| ); |
| })} |
| {/* Gradient for server heap */} |
| <linearGradient id="grad-heap" x1="0" y1="0" x2="0" y2="1"> |
| <stop offset="0%" stopColor="#94a3b8" stopOpacity={0.2} /> |
| <stop offset="95%" stopColor="#94a3b8" stopOpacity={0.01} /> |
| </linearGradient> |
| </defs> |
| |
| <CartesianGrid |
| strokeDasharray="3 3" |
| stroke="rgba(255,255,255,0.04)" |
| vertical={false} |
| /> |
| |
| <XAxis |
| dataKey="timestamp" |
| tickFormatter={formatTime} |
| stroke="#64748b" |
| fontSize={11} |
| tickLine={false} |
| axisLine={false} |
| /> |
| <YAxis |
| yAxisId="tabs" |
| stroke="#64748b" |
| fontSize={11} |
| allowDecimals={false} |
| domain={[0, "auto"]} |
| tickLine={false} |
| axisLine={false} |
| width={30} |
| tickFormatter={(value) => formatMetricValue(Number(value))} |
| /> |
| {(hasMemory || hasServer) && ( |
| <YAxis |
| yAxisId="memory" |
| orientation="right" |
| stroke="#94a3b8" |
| fontSize={11} |
| allowDecimals={false} |
| domain={[0, "auto"]} |
| tickLine={false} |
| axisLine={false} |
| width={40} |
| tickFormatter={(value) => |
| `${formatMetricValue(Number(value), 1)}MB` |
| } |
| /> |
| )} |
| |
| <Tooltip content={tooltipContent} cursor={<AnimatedCursor />} /> |
| |
| {/* Tab count areas (solid gradient fill) */} |
| {instances.map((inst) => ( |
| <Area |
| key={inst.id} |
| yAxisId="tabs" |
| type="monotone" |
| dataKey={inst.id} |
| name={inst.id} |
| stroke={instanceColors[inst.id]} |
| strokeWidth={selectedInstanceId === inst.id ? 2.5 : 1.5} |
| strokeLinecap="round" |
| strokeOpacity={ |
| selectedInstanceId && selectedInstanceId !== inst.id ? 0.3 : 1 |
| } |
| fill={`url(#grad-${inst.id})`} |
| fillOpacity={ |
| selectedInstanceId && selectedInstanceId !== inst.id |
| ? 0.15 |
| : 1 |
| } |
| dot={false} |
| activeDot={{ |
| r: 5, |
| strokeWidth: 2, |
| stroke: instanceColors[inst.id], |
| fill: "#0f1117", |
| onClick: () => onSelectInstance(inst.id), |
| style: { |
| cursor: "pointer", |
| filter: `drop-shadow(0 0 4px ${instanceColors[inst.id]}80)`, |
| }, |
| }} |
| animationDuration={800} |
| animationEasing="ease-in-out" |
| /> |
| ))} |
| |
| {/* Memory areas (dashed, lighter fill) */} |
| {hasMemory && |
| instances.map((inst) => ( |
| <Area |
| key={`${inst.id}_mem`} |
| yAxisId="memory" |
| type="monotone" |
| dataKey={`${inst.id}_mem`} |
| name={`${inst.id}_mem`} |
| stroke={instanceColors[inst.id]} |
| strokeWidth={selectedInstanceId === inst.id ? 2 : 1} |
| strokeLinecap="round" |
| strokeOpacity={ |
| selectedInstanceId && selectedInstanceId !== inst.id |
| ? 0.2 |
| : 0.6 |
| } |
| strokeDasharray="4 2" |
| fill="transparent" |
| dot={false} |
| animationDuration={800} |
| animationEasing="ease-in-out" |
| /> |
| ))} |
| |
| {/* Server heap area (dotted, gray) */} |
| {hasServer && ( |
| <Area |
| yAxisId="memory" |
| type="monotone" |
| dataKey="goHeapMB" |
| name="goHeapMB" |
| stroke="#94a3b8" |
| strokeWidth={1.5} |
| strokeLinecap="round" |
| strokeDasharray="2 2" |
| fill="url(#grad-heap)" |
| fillOpacity={0.5} |
| dot={false} |
| animationDuration={800} |
| animationEasing="ease-in-out" |
| /> |
| )} |
| </AreaChart> |
| </ResponsiveContainer> |
| </div> |
| </div> |
| ); |
| } |
|
|