ComposedChart.utils.tsx 13 KB

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