QueryInsightsChart.tsx 9.9 KB

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