ComposedChart.utils.tsx403 lines · main
1'use client'
2
3import { useState } from 'react'
4import { cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui'
5
6import { CHART_COLORS, DateTimeFormats } from './Charts.constants'
7import { formatPercentage, numberFormatter } from './Charts.utils'
8import { useFormatDateTime, useTimezone } from '@/lib/datetime'
9import { formatBytes, formatBytesMinMB } from '@/lib/helpers'
10
11export interface ReportAttributes {
12 id?: string
13 titleTooltip?: string
14 label: string
15 attributes?: (MultiAttribute | false)[]
16 defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine'
17 hide?: boolean
18 entitlement?: string
19 requiredPlan?: string
20 hideChartType?: boolean
21 format?: string
22 className?: string
23 showTooltip?: boolean
24 showLegend?: boolean
25 showTotal?: boolean
26 showMaxValue?: boolean
27 valuePrecision?: number
28 docsUrl?: string
29 syncId?: string
30 showGrid?: boolean
31 YAxisProps?: {
32 width?: number
33 tickFormatter?: (value: any) => string
34 domain?: [number | string, number | string]
35 allowDataOverflow?: boolean
36 }
37 normalizeVisibleStackToPercent?: boolean
38 hideHighlightedValue?: boolean
39}
40
41export type Provider = 'infra-monitoring' | 'daily-stats' | 'mock' | 'reference-line' | 'logs'
42
43export type MultiAttribute = {
44 attribute: string
45 provider?: Provider
46 label?: string
47 color?: {
48 light: string
49 dark: string
50 }
51 fill?: {
52 light?: string
53 dark?: string
54 }
55 statusCode?: string
56 grantType?: string
57 providerType?: string
58 stackId?: string
59 format?: string
60 description?: string
61 docsLink?: string
62 isMaxValue?: boolean
63 type?: 'line' | 'area-bar'
64 omitFromTotal?: boolean
65 tooltip?: string
66 customValue?: number
67 [key: string]: any
68 /**
69 * Manipulate the value of the attribute before it is displayed on the chart.
70 * @param value - The value of the attribute.
71 * @returns The manipulated value.
72 */
73 manipulateValue?: (value: number) => number
74 /**
75 * Create a virtual attribute by combining values from other attributes.
76 * Expression should use attribute names and basic math operators (+, -, *, /).
77 * Example: 'disk_fs_used - pg_database_size - disk_fs_used_wal'
78 */
79 combine?: string
80 id?: string
81 value?: number
82 isReferenceLine?: boolean
83 strokeDasharray?: string
84 className?: string
85 hide?: boolean
86 enabled?: boolean
87}
88
89interface CustomIconProps {
90 color: string
91}
92
93const CustomIcon = ({ color }: CustomIconProps) => (
94 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
95 <circle cx="5" cy="5" r="3" fill={color} />
96 </svg>
97)
98
99const MaxConnectionsIcon = ({ color }: { color?: string }) => (
100 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
101 <line
102 x1="2"
103 y1="6"
104 x2="12"
105 y2="6"
106 stroke={color ?? CHART_COLORS.REFERENCE_LINE}
107 strokeWidth="2"
108 strokeDasharray="2 2"
109 />
110 </svg>
111)
112
113interface TooltipProps {
114 active?: boolean
115 payload?: any[]
116 label?: string | number
117 attributes?: MultiAttribute[]
118 data?: Record<string, unknown>[]
119 xAxisKey?: string
120 isPercentage?: boolean
121 format?: string | ((value: unknown) => string)
122 valuePrecision?: number
123 showMaxValue?: boolean
124 showTotal?: boolean
125 isActiveHoveredChart?: boolean
126}
127
128const isMaxAttribute = (attributes?: MultiAttribute[]) => attributes?.find((a) => a.isMaxValue)
129
130/**
131 * Calculate the total aggregate of the chart values
132 * by summing the values of the attributes
133 * that are not in the `ignoreAttributes` array
134 */
135export const calculateTotalChartAggregate = (
136 payload: { dataKey: string; value: number }[],
137 ignoreAttributes?: string[]
138) =>
139 payload
140 ?.filter((p) => !ignoreAttributes?.includes(p.dataKey))
141 .reduce((acc, curr) => acc + curr.value, 0)
142
143export const CustomTooltip = ({
144 active,
145 payload,
146 label: _label,
147 attributes,
148 data,
149 xAxisKey = 'period_start',
150 isPercentage,
151 format,
152 valuePrecision,
153 showTotal,
154 isActiveHoveredChart,
155}: TooltipProps) => {
156 const formatDateTime = useFormatDateTime()
157 const { timezone } = useTimezone()
158 if (active && payload && payload.length) {
159 /**
160 * Depending on the data source, the timestamp key could be 'timestamp' or 'period_start'
161 */
162 const firstItem = payload[0].payload
163 const timestampKey = firstItem?.hasOwnProperty('timestamp') ? 'timestamp' : 'period_start'
164 const timestamp = payload[0].payload[timestampKey]
165 const rawDataPoint = data?.find(
166 (point) => point[xAxisKey] === timestamp || point[timestampKey] === timestamp
167 )
168 const maxValueAttribute = isMaxAttribute(attributes)
169 const maxValue =
170 maxValueAttribute && rawDataPoint
171 ? Number(rawDataPoint[maxValueAttribute.attribute])
172 : undefined
173 const hasFiniteMaxValue = typeof maxValue === 'number' && Number.isFinite(maxValue)
174 const isRamChart =
175 !payload?.some((p: any) => p.dataKey.toLowerCase() === 'ram_usage') &&
176 payload?.some((p: any) => p.dataKey.toLowerCase().includes('ram_'))
177 const isSwapChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('swap_'))
178 const isMemoryChart = isRamChart || isSwapChart
179 const isDBSizeChart =
180 payload?.some((p: any) => p.dataKey.toLowerCase().includes('disk_fs_')) ||
181 payload?.some((p: any) => p.dataKey.toLowerCase().includes('pg_database_size'))
182 const isNetworkChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('network_'))
183 const isBytesFormat = format === 'bytes' || format === 'bytes-per-second'
184 const shouldFormatBytes = isBytesFormat || isMemoryChart || isDBSizeChart || isNetworkChart
185 const byteUnitSuffix = format === 'bytes-per-second' ? '/s' : ''
186
187 const attributesToIgnore =
188 attributes?.filter((a) => a.omitFromTotal)?.map((a) => a.attribute) ?? []
189 const referenceLines =
190 attributes
191 ?.filter((attribute: MultiAttribute) => attribute?.provider === 'reference-line')
192 ?.map((a: MultiAttribute) => a.attribute) ?? []
193
194 const attributesToIgnoreFromTotal = [
195 ...attributesToIgnore,
196 ...referenceLines,
197 ...(maxValueAttribute?.attribute ? [maxValueAttribute.attribute] : []),
198 ]
199
200 const localTimeZone = timezone
201
202 const rawPayload = payload.map((entry: any) => ({
203 ...entry,
204 value:
205 rawDataPoint && typeof rawDataPoint[entry.dataKey] === 'number'
206 ? Number(rawDataPoint[entry.dataKey])
207 : entry.value,
208 }))
209
210 const total = showTotal && calculateTotalChartAggregate(rawPayload, attributesToIgnoreFromTotal)
211
212 const getIcon = (color: string, isMax: boolean) =>
213 isMax ? <MaxConnectionsIcon /> : <CustomIcon color={color} />
214
215 const formatNumeric = (value: number) => {
216 if (!shouldFormatBytes && valuePrecision === 0 && value > 0 && value < 1) return '<1'
217 if (shouldFormatBytes) {
218 const val = isNetworkChart ? Math.abs(value) : value
219 if (isMemoryChart) return formatBytesMinMB(val, valuePrecision)
220 return formatBytes(val, valuePrecision)
221 }
222 const formatted = numberFormatter(value, valuePrecision)
223 if (
224 !isBytesFormat &&
225 format !== '%' &&
226 format !== 'ms' &&
227 typeof format === 'string' &&
228 format
229 ) {
230 return `${formatted}${format}`
231 }
232 return formatted
233 }
234
235 const LabelItem = ({ entry }: { entry: any }) => {
236 const attribute = attributes?.find((a: MultiAttribute) => a?.attribute === entry.name)
237 const rawValue =
238 rawDataPoint && typeof rawDataPoint[entry.dataKey] === 'number'
239 ? Number(rawDataPoint[entry.dataKey])
240 : entry.value
241 const percentage =
242 hasFiniteMaxValue && maxValue > 0
243 ? ((rawValue / maxValue) * 100).toFixed(valuePrecision)
244 : null
245 const isMax = entry.dataKey === maxValueAttribute?.attribute
246
247 return (
248 <div key={entry.name} className="flex items-center w-full">
249 {getIcon(entry.color, isMax)}
250 <span className="text-foreground-lighter ml-1 grow cursor-default select-none">
251 {attribute?.label || entry.name}
252 </span>
253 <span className="ml-3.5 flex items-end gap-1">
254 {formatNumeric(rawValue) + (!isPercentage && format !== 'ms' ? byteUnitSuffix : '')}
255 {isPercentage ? '%' : ''}
256 {format === 'ms' ? 'ms' : ''}
257
258 {/* Show percentage if max value is set */}
259 {percentage !== null && !isMax && !isPercentage && (
260 <span className="text-[11px] text-foreground-light mb-0.5">({percentage}%)</span>
261 )}
262 </span>
263 </div>
264 )
265 }
266
267 return (
268 <div
269 className={cn(
270 'grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-default px-2.5 py-1.5 text-xs shadow-xl transition-opacity opacity-100',
271 !isActiveHoveredChart && 'opacity-0'
272 )}
273 >
274 <p className="text-foreground-light text-xs">{localTimeZone}</p>
275 <p className="font-medium">{formatDateTime(timestamp, DateTimeFormats.FULL_SECONDS)}</p>
276 <div className="grid gap-0">
277 {[...payload].reverse().map((entry: any, index: number) => (
278 <LabelItem key={`${entry.name}-${index}`} entry={entry} />
279 ))}
280 {active && showTotal && (
281 <div className="flex md:flex-col gap-1 md:gap-0 text-foreground mt-1">
282 <span className="grow text-foreground-lighter">Total</span>
283 <div className="flex items-end gap-1">
284 <span className="text-base">
285 {isPercentage
286 ? formatPercentage(total as number, valuePrecision)
287 : formatNumeric(total as number) +
288 (!isPercentage && format !== 'ms' ? byteUnitSuffix : '')}
289 {format === 'ms' ? 'ms' : ''}
290 </span>
291 {maxValueAttribute &&
292 hasFiniteMaxValue &&
293 !isPercentage &&
294 !isNaN((total as number) / maxValue) &&
295 isFinite((total as number) / maxValue) && (
296 <span className="text-[11px] text-foreground-light mb-0.5">
297 ({(((total as number) / maxValue) * 100).toFixed(1)}%)
298 </span>
299 )}
300 </div>
301 </div>
302 )}
303 </div>
304 </div>
305 )
306 }
307
308 return null
309}
310
311interface CustomLabelProps {
312 payload?: any[]
313 attributes?: MultiAttribute[]
314 showMaxValue?: boolean
315 onLabelHover?: (label: string | null) => void
316 onToggleAttribute?: (attribute: string, options?: { exclusive?: boolean }) => void
317 hiddenAttributes?: Set<string>
318}
319
320export const CustomLabel = ({
321 payload,
322 attributes,
323 showMaxValue,
324 onLabelHover,
325 onToggleAttribute,
326 hiddenAttributes,
327}: CustomLabelProps) => {
328 const items = payload ?? []
329 const maxValueAttribute = isMaxAttribute(attributes)
330 const [, setHoveredLabel] = useState<string | null>(null)
331
332 const handleMouseEnter = (label: string) => {
333 setHoveredLabel(label)
334 onLabelHover?.(label)
335 }
336
337 const handleMouseLeave = () => {
338 setHoveredLabel(null)
339 onLabelHover?.(null)
340 }
341
342 const getIcon = (name: string, color: string) => {
343 switch (name === maxValueAttribute?.attribute) {
344 case true:
345 return <MaxConnectionsIcon />
346 default:
347 return <CustomIcon color={color} />
348 }
349 }
350
351 const LabelItem = ({ entry }: { entry: any }) => {
352 const attribute = attributes?.find((a) => a.attribute === entry.name)
353 const isMax = entry.name === maxValueAttribute?.attribute
354 const isHidden = hiddenAttributes?.has(entry.name)
355 const color = isHidden ? 'gray' : entry.color
356
357 const Label = () => (
358 <div className="flex items-center gap-1">
359 {getIcon(entry.name, color)}
360 <span className={cn('text-nowrap text-foreground-lighter', isHidden && 'opacity-50')}>
361 {attribute?.label || entry.name}
362 </span>
363 </div>
364 )
365
366 if (!showMaxValue && isMax) return null
367
368 return (
369 <button
370 key={entry.name}
371 className="flex md:flex-col gap-1 md:gap-0 w-fit text-foreground rounded-lg hover:bg-background-overlay-hover"
372 onMouseOver={() => handleMouseEnter(entry.name)}
373 onMouseOutCapture={handleMouseLeave}
374 onClick={(e) => onToggleAttribute?.(entry.name, { exclusive: e.metaKey || e.ctrlKey })}
375 >
376 {!!attribute?.tooltip ? (
377 <Tooltip>
378 <TooltipTrigger className="p-1.5">
379 <Label />
380 </TooltipTrigger>
381 <TooltipContent sideOffset={6} side="bottom" align="center" className="max-w-[250px]">
382 {attribute.tooltip}
383 </TooltipContent>
384 </Tooltip>
385 ) : (
386 <Label />
387 )}
388 </button>
389 )
390 }
391
392 return (
393 <div className="relative z-10 mx-auto flex flex-col items-center gap-1 text-xs w-full">
394 <div className="flex flex-wrap items-center justify-center gap-2">
395 <TooltipProvider delayDuration={800}>
396 {items?.map((entry, index) => (
397 <LabelItem key={`${entry.name}-${index}`} entry={entry} />
398 ))}
399 </TooltipProvider>
400 </div>
401 </div>
402 )
403}