ChartHeader.tsx333 lines · main
1import { useParams } from 'common'
2import {
3 Activity,
4 BarChartIcon,
5 GitCommitHorizontalIcon,
6 InfoIcon,
7 SquareTerminal,
8} from 'lucide-react'
9import Link from 'next/link'
10import { useEffect, useState } from 'react'
11import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
12
13import { formatPercentage, numberFormatter } from './Charts.utils'
14import { useChartHoverState } from './useChartHoverState'
15import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
16import { formatDateTime, useFormatDateTime } from '@/lib/datetime'
17import { formatBytes, formatBytesMinMB } from '@/lib/helpers'
18
19export interface ChartHeaderProps {
20 title?: string
21 format?: string | ((value: unknown) => string)
22 customDateFormat?: string
23 minimalHeader?: boolean
24 displayDateInUtc?: boolean
25 highlightedLabel?: number | string | any | null
26 highlightedValue?: number | string | any | null
27 hideHighlightedValue?: boolean
28 hideHighlightedLabel?: boolean
29 hideHighlightArea?: boolean
30 hideChartType?: boolean
31 chartStyle?: string
32 onChartStyleChange?: (style: string) => void
33 showMaxValue?: boolean
34 setShowMaxValue?: (value: boolean) => void
35 docsUrl?: string
36 syncId?: string
37 data?: any[]
38 xAxisKey?: string
39 yAxisKey?: string
40 xAxisIsDate?: boolean
41 valuePrecision?: number
42 shouldFormatBytes?: boolean
43 isNetworkChart?: boolean
44 isMemoryChart?: boolean
45 attributes?: any[]
46 sql?: string
47 titleTooltip?: string
48 showNewBadge?: boolean
49}
50
51export const ChartHeader = ({
52 format,
53 highlightedValue,
54 highlightedLabel,
55 hideHighlightedValue = false,
56 hideHighlightedLabel = false,
57 hideHighlightArea = false,
58 title,
59 minimalHeader = false,
60 hideChartType = false,
61 chartStyle = 'bar',
62 onChartStyleChange,
63 showMaxValue = false,
64 setShowMaxValue,
65 docsUrl,
66 syncId,
67 data,
68 xAxisKey,
69 yAxisKey,
70 xAxisIsDate = true,
71 displayDateInUtc,
72 customDateFormat,
73 valuePrecision = 2,
74 shouldFormatBytes = false,
75 isNetworkChart = false,
76 attributes,
77 sql,
78 titleTooltip,
79 showNewBadge,
80 isMemoryChart,
81}: ChartHeaderProps) => {
82 const { ref } = useParams()
83 const { hoveredIndex, isHovered } = useChartHoverState(syncId || 'default')
84 const [localHighlightedValue, setLocalHighlightedValue] = useState(highlightedValue)
85 const [localHighlightedLabel, setLocalHighlightedLabel] = useState(highlightedLabel)
86
87 // When `displayDateInUtc` is set the chart explicitly wants UTC labels.
88 // Otherwise honour the user's selected timezone via the picker.
89 const formatPickerDate = useFormatDateTime()
90
91 const formatHighlightedValue = (value: any) => {
92 if (typeof value !== 'number') {
93 return value
94 }
95
96 if (typeof format === 'function') {
97 return format(value)
98 }
99
100 if (shouldFormatBytes) {
101 const bytesValue = isNetworkChart ? Math.abs(value) : value
102 return isMemoryChart
103 ? formatBytesMinMB(bytesValue, valuePrecision)
104 : formatBytes(bytesValue, valuePrecision)
105 }
106
107 if (format === '%') {
108 return formatPercentage(value, valuePrecision)
109 }
110
111 const formattedValue = numberFormatter(value, valuePrecision)
112
113 if (typeof format === 'string' && format) {
114 return `${formattedValue} ${format}`
115 }
116
117 return formattedValue
118 }
119
120 useEffect(() => {
121 if (syncId && hoveredIndex !== null && isHovered && data && xAxisKey && yAxisKey) {
122 const activeDataPoint = data[hoveredIndex]
123 if (activeDataPoint) {
124 // For stacked charts, we need to calculate the total of all attributes
125 // that should be included in the total (excluding reference lines, max values, etc.)
126 let newValue = activeDataPoint[yAxisKey]
127
128 // If this is a stacked chart with multiple attributes, calculate the total
129 if (attributes && attributes.length > 1) {
130 const attributesToIgnore =
131 attributes
132 ?.filter((a) => a.omitFromTotal || a.isMaxValue || a.provider === 'reference-line')
133 ?.map((a) => a.attribute) ?? []
134
135 const totalValue = Object.entries(activeDataPoint)
136 .filter(([key, value]) => {
137 // Include only numeric values that are not in the ignore list
138 return (
139 typeof value === 'number' &&
140 key !== 'timestamp' &&
141 key !== 'period_start' &&
142 !attributesToIgnore.includes(key) &&
143 attributes.some((attr) => attr.attribute === key && attr.enabled !== false)
144 )
145 })
146 .reduce((sum, [_, value]) => sum + (value as number), 0)
147
148 newValue = totalValue
149 }
150
151 setLocalHighlightedValue(newValue)
152
153 // Update highlighted label based on sync state
154 let newLabel = highlightedLabel
155 if (xAxisIsDate && activeDataPoint[xAxisKey]) {
156 const value = activeDataPoint[xAxisKey] as number | string
157 const fmt = customDateFormat || 'YYYY-MM-DD HH:mm:ss'
158 newLabel = displayDateInUtc
159 ? formatDateTime(value, { tz: 'UTC', format: fmt })
160 : formatPickerDate(value, fmt)
161 } else if (activeDataPoint[xAxisKey]) {
162 newLabel = activeDataPoint[xAxisKey]
163 }
164 setLocalHighlightedLabel(newLabel)
165 }
166 } else {
167 // Reset to original values when not syncing
168 setLocalHighlightedValue(highlightedValue)
169 setLocalHighlightedLabel(highlightedLabel)
170 }
171 }, [
172 hoveredIndex,
173 isHovered,
174 syncId,
175 data,
176 xAxisKey,
177 yAxisKey,
178 xAxisIsDate,
179 displayDateInUtc,
180 customDateFormat,
181 highlightedValue,
182 highlightedLabel,
183 attributes,
184 formatPickerDate,
185 ])
186
187 const chartTitle = (
188 <div className="flex flex-row items-center gap-x-2">
189 <div className="flex flex-row items-center gap-x-2">
190 <h3 className={'text-foreground-lighter ' + (minimalHeader ? 'text-xs' : 'text-sm')}>
191 {title}
192 </h3>
193 {titleTooltip && (
194 <Tooltip>
195 <TooltipTrigger asChild>
196 <InfoIcon className="w-4 h-4 text-foreground-lighter" />
197 </TooltipTrigger>
198 <TooltipContent side="top" className="max-w-xs">
199 {titleTooltip}
200 {docsUrl && (
201 <>
202 {' '}
203 <Link
204 href={docsUrl}
205 target="_blank"
206 className="underline text-foreground hover:text-foreground-light"
207 >
208 Read docs
209 </Link>
210 </>
211 )}
212 </TooltipContent>
213 </Tooltip>
214 )}
215 </div>
216 {!titleTooltip && docsUrl && (
217 <ButtonTooltip
218 type="text"
219 className="px-1"
220 asChild
221 tooltip={{
222 content: {
223 side: 'top',
224 text: 'Read docs',
225 },
226 }}
227 >
228 <Link href={docsUrl} target="_blank">
229 <InfoIcon className="w-4 h-4 text-foreground-lighter" />
230 </Link>
231 </ButtonTooltip>
232 )}
233 </div>
234 )
235
236 const highlighted = (
237 <h4
238 className={`text-foreground text-xl font-normal ${minimalHeader ? 'text-base' : 'text-2xl'}`}
239 >
240 {localHighlightedValue !== undefined && formatHighlightedValue(localHighlightedValue)}
241 </h4>
242 )
243 const label = <h4 className="text-foreground-lighter text-xs">{localHighlightedLabel}</h4>
244
245 if (minimalHeader) {
246 return (
247 <div
248 className={cn('flex flex-row items-center gap-x-4', hideHighlightArea && 'hidden')}
249 style={{ minHeight: '1.8rem' }}
250 >
251 {title && chartTitle}
252 <div className="flex flex-row items-baseline gap-x-2">
253 {highlightedValue !== undefined && !hideHighlightedValue && highlighted}
254 {!hideHighlightedLabel && label}
255 </div>
256 </div>
257 )
258 }
259
260 const hasHighlightedValue = highlightedValue !== undefined && !hideHighlightedValue
261
262 return (
263 <div
264 className={cn(
265 'grow flex justify-between items-start min-h-16',
266 hideHighlightArea && 'hidden'
267 )}
268 >
269 <div className="flex flex-col">
270 <div className="flex items-center gap-2">
271 {title && chartTitle}
272 {showNewBadge && <Badge variant="success">New</Badge>}
273 </div>
274 <div className="h-4">
275 {hasHighlightedValue && highlighted}
276 {!hideHighlightedLabel && label}
277 </div>
278 </div>
279 <div className="flex items-center gap-2">
280 {sql ? (
281 <ButtonTooltip
282 type="default"
283 className="px-1.5"
284 asChild
285 tooltip={{
286 content: {
287 side: 'top',
288 text: 'Open in Log Explorer',
289 },
290 }}
291 >
292 <Link href={`/project/${ref}/logs/explorer?q=${encodeURIComponent(sql)}`}>
293 <SquareTerminal className="w-4 h-4 text-foreground-lighter" />
294 </Link>
295 </ButtonTooltip>
296 ) : null}
297
298 {!hideChartType && onChartStyleChange && (
299 <ButtonTooltip
300 type="default"
301 className="px-1.5"
302 icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />}
303 onClick={() => onChartStyleChange(chartStyle === 'bar' ? 'line' : 'bar')}
304 tooltip={{
305 content: {
306 side: 'top',
307 text: `View as ${chartStyle === 'bar' ? 'line chart' : 'bar chart'}`,
308 },
309 }}
310 />
311 )}
312 {setShowMaxValue && (
313 <ButtonTooltip
314 type={showMaxValue ? 'default' : 'dashed'}
315 className="px-1.5"
316 icon={
317 <GitCommitHorizontalIcon
318 className={showMaxValue ? 'text-foreground-light' : 'text-foreground-lighter'}
319 />
320 }
321 onClick={() => setShowMaxValue(!showMaxValue)}
322 tooltip={{
323 content: {
324 side: 'top',
325 text: `${showMaxValue ? 'Hide' : 'Show'} limit`,
326 },
327 }}
328 />
329 )}
330 </div>
331 </div>
332 )
333}