ComposedChart.utils.tsx403 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import { useState } from 'react' |
| 4 | import { cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' |
| 5 | |
| 6 | import { CHART_COLORS, DateTimeFormats } from './Charts.constants' |
| 7 | import { formatPercentage, numberFormatter } from './Charts.utils' |
| 8 | import { useFormatDateTime, useTimezone } from '@/lib/datetime' |
| 9 | import { formatBytes, formatBytesMinMB } from '@/lib/helpers' |
| 10 | |
| 11 | export 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 | |
| 41 | export type Provider = 'infra-monitoring' | 'daily-stats' | 'mock' | 'reference-line' | 'logs' |
| 42 | |
| 43 | export 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 | |
| 89 | interface CustomIconProps { |
| 90 | color: string |
| 91 | } |
| 92 | |
| 93 | const 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 | |
| 99 | const 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 | |
| 113 | interface 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 | |
| 128 | const 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 | */ |
| 135 | export 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 | |
| 143 | export 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 | |
| 311 | interface 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 | |
| 320 | export 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 | } |