QueryInsightsChart.tsx260 lines · main
1import { Loader2 } from 'lucide-react'
2import { useTheme } from 'next-themes'
3import { useMemo, useState } from 'react'
4import {
5 Area,
6 AreaChart,
7 CartesianGrid,
8 ResponsiveContainer,
9 Tooltip,
10 XAxis,
11 YAxis,
12} from 'recharts'
13import { cn, Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
14
15import type { ChartDataPoint } from '../QueryInsights.types'
16import { CHART_TABS, CHART_TYPE, LEGEND_ITEMS, SEL_COLOR } from './QueryInsightsChart.constants'
17import { formatTime } from './QueryInsightsChart.utils'
18import { QueryInsightsChartTooltip } from './QueryInsightsChartTooltip'
19
20interface QueryInsightsChartProps {
21 chartData: ChartDataPoint[]
22 selectedChartData?: ChartDataPoint[]
23 isLoading: boolean
24}
25
26export const QueryInsightsChart = ({
27 chartData,
28 selectedChartData,
29 isLoading,
30}: QueryInsightsChartProps) => {
31 const [selectedMetric, setSelectedMetric] = useState('query_latency')
32 const [hiddenSeries, setHiddenSeries] = useState<Set<string>>(new Set())
33 const { resolvedTheme } = useTheme()
34 const isDarkMode = resolvedTheme?.includes('dark')
35
36 const data = useMemo(() => {
37 const normalize = (ts: number) => (ts > 1e13 ? Math.floor(ts / 1000) : ts)
38 const selByTime = new Map((selectedChartData ?? []).map((d) => [normalize(d.period_start), d]))
39
40 return chartData.map((d) => {
41 const t = normalize(d.period_start)
42 const sel = selByTime.get(t)
43 return {
44 time: t,
45 p50: d.p50_time,
46 p95: d.p95_time,
47 rows_read: d.rows_read,
48 calls: d.calls,
49 cache_hits: d.cache_hits,
50 sel_p50: sel?.p50_time,
51 sel_rows_read: sel?.rows_read,
52 sel_calls: sel?.calls,
53 sel_cache_hits: sel?.cache_hits,
54 }
55 })
56 }, [chartData, selectedChartData])
57
58 const filteredData = useMemo(() => {
59 if (hiddenSeries.size === 0) return data
60 return data.map((point) => {
61 const filtered = { ...point } as Record<string, number | undefined>
62 hiddenSeries.forEach((key) => {
63 filtered[key] = undefined
64 })
65 return filtered
66 })
67 }, [data, hiddenSeries])
68
69 const toggleSeries = (dataKey: string) => {
70 setHiddenSeries((prev) => {
71 const next = new Set(prev)
72 if (next.has(dataKey)) {
73 next.delete(dataKey)
74 } else {
75 next.add(dataKey)
76 }
77 return next
78 })
79 }
80
81 const hasSelection = !!selectedChartData && selectedChartData.length > 0
82 const selDataKey = selectedMetric === 'query_latency' ? 'sel_p50' : `sel_${selectedMetric}`
83 const legendItems = LEGEND_ITEMS[selectedMetric] ?? []
84
85 return (
86 <div className="bg-surface-100 border-b min-h-[320px]">
87 <Tabs_Shadcn_ value={selectedMetric} onValueChange={setSelectedMetric} className="w-full">
88 <TabsList_Shadcn_ className="flex justify-start rounded-none gap-x-4 border-b mt-0! pt-0 px-6">
89 {CHART_TABS.map((tab) => (
90 <TabsTrigger_Shadcn_
91 key={tab.id}
92 value={tab.id}
93 className="flex items-center gap-2 text-xs py-3 border-b font-mono uppercase"
94 >
95 {tab.label}
96 </TabsTrigger_Shadcn_>
97 ))}
98 </TabsList_Shadcn_>
99
100 <TabsContent_Shadcn_ value={selectedMetric} className="bg-surface-100 mt-0">
101 <div className="w-full gap-4 mt-4 px-6 flex items-center justify-end">
102 {legendItems.map((item: { dataKey: string; label: string; color: string }) => (
103 <button
104 key={item.dataKey}
105 type="button"
106 onClick={() => toggleSeries(item.dataKey)}
107 className={cn(
108 'flex items-center gap-1.5 text-[11px] transition-colors cursor-pointer',
109 !hiddenSeries.has(item.dataKey)
110 ? 'text-foreground hover:text-foreground-light'
111 : 'text-foreground-muted'
112 )}
113 >
114 <span
115 className={cn(
116 'h-1.5 w-1.5 rounded-full transition-opacity',
117 hiddenSeries.has(item.dataKey) && 'opacity-30'
118 )}
119 style={{ backgroundColor: item.color }}
120 />
121 {item.label}
122 </button>
123 ))}
124 {hasSelection && (
125 <button
126 type="button"
127 onClick={() => toggleSeries(selDataKey)}
128 className={cn(
129 'flex items-center gap-1.5 text-[11px] transition-colors cursor-pointer',
130 !hiddenSeries.has(selDataKey)
131 ? 'text-foreground hover:text-foreground-light'
132 : 'text-foreground-muted'
133 )}
134 >
135 <span
136 className={cn(
137 'h-1.5 w-1.5 rounded-xs transition-opacity',
138 hiddenSeries.has(selDataKey) && 'opacity-30'
139 )}
140 style={{ backgroundColor: SEL_COLOR }}
141 />
142 Selected query
143 </button>
144 )}
145 </div>
146 <div className="w-full py-4 flex flex-col">
147 <div className="w-full h-[180px] px-0">
148 {isLoading ? (
149 <div className="flex items-center justify-center h-full">
150 <Loader2 size={20} className="animate-spin text-foreground-lighter" />
151 </div>
152 ) : data.length === 0 ? (
153 <div className="flex items-center justify-center h-full">
154 <p className="text-sm text-foreground-lighter">No data available</p>
155 </div>
156 ) : (
157 <ResponsiveContainer width="100%" height="100%">
158 <AreaChart data={filteredData} margin={{ top: 4, left: 0, right: 0, bottom: 4 }}>
159 <defs>
160 {legendItems.map((item) => (
161 <linearGradient
162 key={`gradient-${item.dataKey}`}
163 id={`gradient-${item.dataKey}`}
164 x1="0"
165 y1="0"
166 x2="0"
167 y2="1"
168 >
169 <stop offset="0%" stopColor={item.color} stopOpacity={0.15} />
170 <stop offset="100%" stopColor={item.color} stopOpacity={0} />
171 </linearGradient>
172 ))}
173 {hasSelection && (
174 <linearGradient id={`gradient-${selDataKey}`} x1="0" y1="0" x2="0" y2="1">
175 <stop offset="0%" stopColor={SEL_COLOR} stopOpacity={0.35} />
176 <stop offset="100%" stopColor={SEL_COLOR} stopOpacity={0} />
177 </linearGradient>
178 )}
179 </defs>
180 <XAxis
181 dataKey="time"
182 tick={false}
183 tickLine={false}
184 axisLine={{ stroke: 'hsl(var(--border-default))' }}
185 height={1}
186 />
187 <YAxis
188 axisLine={false}
189 tickLine={false}
190 tick={{ fontSize: 10, fill: 'hsl(var(--foreground-muted))' }}
191 tickCount={3}
192 width={40}
193 orientation="left"
194 tickFormatter={(v) =>
195 selectedMetric === 'query_latency'
196 ? `${Math.round(v)}ms`
197 : `${Math.round(v)}`
198 }
199 mirror={true}
200 />
201 <Tooltip
202 content={<QueryInsightsChartTooltip />}
203 cursor={{
204 stroke: isDarkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)',
205 strokeWidth: 1,
206 }}
207 />
208 <CartesianGrid
209 horizontal={true}
210 vertical={false}
211 stroke="hsl(var(--border-default))"
212 strokeOpacity={0.5}
213 />
214 {legendItems.map((item) => (
215 <Area
216 key={item.dataKey}
217 type={CHART_TYPE}
218 dataKey={item.dataKey}
219 stroke={item.color}
220 strokeWidth={1}
221 fill={`url(#gradient-${item.dataKey})`}
222 dot={false}
223 name={item.label}
224 strokeOpacity={
225 !hiddenSeries.has(item.dataKey) ? (hasSelection ? 0.2 : 1) : 0
226 }
227 fillOpacity={!hiddenSeries.has(item.dataKey) ? (hasSelection ? 0.2 : 1) : 0}
228 />
229 ))}
230 {hasSelection && (
231 <Area
232 type={CHART_TYPE}
233 dataKey={selDataKey}
234 stroke={SEL_COLOR}
235 strokeWidth={1}
236 fill={`url(#gradient-${selDataKey})`}
237 dot={false}
238 name="Selected query"
239 connectNulls={false}
240 strokeOpacity={!hiddenSeries.has(selDataKey) ? 1 : 0}
241 fillOpacity={!hiddenSeries.has(selDataKey) ? 1 : 0}
242 />
243 )}
244 </AreaChart>
245 </ResponsiveContainer>
246 )}
247 </div>
248
249 {data.length > 0 && (
250 <div className="flex justify-between text-xs text-foreground-lighter pt-2 px-6">
251 <span>{formatTime(data[0].time)}</span>
252 <span>{formatTime(data[data.length - 1].time)}</span>
253 </div>
254 )}
255 </div>
256 </TabsContent_Shadcn_>
257 </Tabs_Shadcn_>
258 </div>
259 )
260}