| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- 'use client'
- import { useState } from 'react'
- import { cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui'
- import { CHART_COLORS, DateTimeFormats } from './Charts.constants'
- import { formatPercentage, numberFormatter } from './Charts.utils'
- import { useFormatDateTime, useTimezone } from '@/lib/datetime'
- import { formatBytes, formatBytesMinMB } from '@/lib/helpers'
- export interface ReportAttributes {
- id?: string
- titleTooltip?: string
- label: string
- attributes?: (MultiAttribute | false)[]
- defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine'
- hide?: boolean
- entitlement?: string
- requiredPlan?: string
- hideChartType?: boolean
- format?: string
- className?: string
- showTooltip?: boolean
- showLegend?: boolean
- showTotal?: boolean
- showMaxValue?: boolean
- valuePrecision?: number
- docsUrl?: string
- syncId?: string
- showGrid?: boolean
- YAxisProps?: {
- width?: number
- tickFormatter?: (value: any) => string
- domain?: [number | string, number | string]
- allowDataOverflow?: boolean
- }
- normalizeVisibleStackToPercent?: boolean
- hideHighlightedValue?: boolean
- }
- export type Provider = 'infra-monitoring' | 'daily-stats' | 'mock' | 'reference-line' | 'logs'
- export type MultiAttribute = {
- attribute: string
- provider?: Provider
- label?: string
- color?: {
- light: string
- dark: string
- }
- fill?: {
- light?: string
- dark?: string
- }
- statusCode?: string
- grantType?: string
- providerType?: string
- stackId?: string
- format?: string
- description?: string
- docsLink?: string
- isMaxValue?: boolean
- type?: 'line' | 'area-bar'
- omitFromTotal?: boolean
- tooltip?: string
- customValue?: number
- [key: string]: any
- /**
- * Manipulate the value of the attribute before it is displayed on the chart.
- * @param value - The value of the attribute.
- * @returns The manipulated value.
- */
- manipulateValue?: (value: number) => number
- /**
- * Create a virtual attribute by combining values from other attributes.
- * Expression should use attribute names and basic math operators (+, -, *, /).
- * Example: 'disk_fs_used - pg_database_size - disk_fs_used_wal'
- */
- combine?: string
- id?: string
- value?: number
- isReferenceLine?: boolean
- strokeDasharray?: string
- className?: string
- hide?: boolean
- enabled?: boolean
- }
- interface CustomIconProps {
- color: string
- }
- const CustomIcon = ({ color }: CustomIconProps) => (
- <svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
- <circle cx="5" cy="5" r="3" fill={color} />
- </svg>
- )
- const MaxConnectionsIcon = ({ color }: { color?: string }) => (
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
- <line
- x1="2"
- y1="6"
- x2="12"
- y2="6"
- stroke={color ?? CHART_COLORS.REFERENCE_LINE}
- strokeWidth="2"
- strokeDasharray="2 2"
- />
- </svg>
- )
- interface TooltipProps {
- active?: boolean
- payload?: any[]
- label?: string | number
- attributes?: MultiAttribute[]
- data?: Record<string, unknown>[]
- xAxisKey?: string
- isPercentage?: boolean
- format?: string | ((value: unknown) => string)
- valuePrecision?: number
- showMaxValue?: boolean
- showTotal?: boolean
- isActiveHoveredChart?: boolean
- }
- const isMaxAttribute = (attributes?: MultiAttribute[]) => attributes?.find((a) => a.isMaxValue)
- /**
- * Calculate the total aggregate of the chart values
- * by summing the values of the attributes
- * that are not in the `ignoreAttributes` array
- */
- export const calculateTotalChartAggregate = (
- payload: { dataKey: string; value: number }[],
- ignoreAttributes?: string[]
- ) =>
- payload
- ?.filter((p) => !ignoreAttributes?.includes(p.dataKey))
- .reduce((acc, curr) => acc + curr.value, 0)
- export const CustomTooltip = ({
- active,
- payload,
- label: _label,
- attributes,
- data,
- xAxisKey = 'period_start',
- isPercentage,
- format,
- valuePrecision,
- showTotal,
- isActiveHoveredChart,
- }: TooltipProps) => {
- const formatDateTime = useFormatDateTime()
- const { timezone } = useTimezone()
- if (active && payload && payload.length) {
- /**
- * Depending on the data source, the timestamp key could be 'timestamp' or 'period_start'
- */
- const firstItem = payload[0].payload
- const timestampKey = firstItem?.hasOwnProperty('timestamp') ? 'timestamp' : 'period_start'
- const timestamp = payload[0].payload[timestampKey]
- const rawDataPoint = data?.find(
- (point) => point[xAxisKey] === timestamp || point[timestampKey] === timestamp
- )
- const maxValueAttribute = isMaxAttribute(attributes)
- const maxValue =
- maxValueAttribute && rawDataPoint
- ? Number(rawDataPoint[maxValueAttribute.attribute])
- : undefined
- const hasFiniteMaxValue = typeof maxValue === 'number' && Number.isFinite(maxValue)
- const isRamChart =
- !payload?.some((p: any) => p.dataKey.toLowerCase() === 'ram_usage') &&
- payload?.some((p: any) => p.dataKey.toLowerCase().includes('ram_'))
- const isSwapChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('swap_'))
- const isMemoryChart = isRamChart || isSwapChart
- const isDBSizeChart =
- payload?.some((p: any) => p.dataKey.toLowerCase().includes('disk_fs_')) ||
- payload?.some((p: any) => p.dataKey.toLowerCase().includes('pg_database_size'))
- const isNetworkChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('network_'))
- const isBytesFormat = format === 'bytes' || format === 'bytes-per-second'
- const shouldFormatBytes = isBytesFormat || isMemoryChart || isDBSizeChart || isNetworkChart
- const byteUnitSuffix = format === 'bytes-per-second' ? '/s' : ''
- const attributesToIgnore =
- attributes?.filter((a) => a.omitFromTotal)?.map((a) => a.attribute) ?? []
- const referenceLines =
- attributes
- ?.filter((attribute: MultiAttribute) => attribute?.provider === 'reference-line')
- ?.map((a: MultiAttribute) => a.attribute) ?? []
- const attributesToIgnoreFromTotal = [
- ...attributesToIgnore,
- ...referenceLines,
- ...(maxValueAttribute?.attribute ? [maxValueAttribute.attribute] : []),
- ]
- const localTimeZone = timezone
- const rawPayload = payload.map((entry: any) => ({
- ...entry,
- value:
- rawDataPoint && typeof rawDataPoint[entry.dataKey] === 'number'
- ? Number(rawDataPoint[entry.dataKey])
- : entry.value,
- }))
- const total = showTotal && calculateTotalChartAggregate(rawPayload, attributesToIgnoreFromTotal)
- const getIcon = (color: string, isMax: boolean) =>
- isMax ? <MaxConnectionsIcon /> : <CustomIcon color={color} />
- const formatNumeric = (value: number) => {
- if (!shouldFormatBytes && valuePrecision === 0 && value > 0 && value < 1) return '<1'
- if (shouldFormatBytes) {
- const val = isNetworkChart ? Math.abs(value) : value
- if (isMemoryChart) return formatBytesMinMB(val, valuePrecision)
- return formatBytes(val, valuePrecision)
- }
- const formatted = numberFormatter(value, valuePrecision)
- if (
- !isBytesFormat &&
- format !== '%' &&
- format !== 'ms' &&
- typeof format === 'string' &&
- format
- ) {
- return `${formatted}${format}`
- }
- return formatted
- }
- const LabelItem = ({ entry }: { entry: any }) => {
- const attribute = attributes?.find((a: MultiAttribute) => a?.attribute === entry.name)
- const rawValue =
- rawDataPoint && typeof rawDataPoint[entry.dataKey] === 'number'
- ? Number(rawDataPoint[entry.dataKey])
- : entry.value
- const percentage =
- hasFiniteMaxValue && maxValue > 0
- ? ((rawValue / maxValue) * 100).toFixed(valuePrecision)
- : null
- const isMax = entry.dataKey === maxValueAttribute?.attribute
- return (
- <div key={entry.name} className="flex items-center w-full">
- {getIcon(entry.color, isMax)}
- <span className="text-foreground-lighter ml-1 grow cursor-default select-none">
- {attribute?.label || entry.name}
- </span>
- <span className="ml-3.5 flex items-end gap-1">
- {formatNumeric(rawValue) + (!isPercentage && format !== 'ms' ? byteUnitSuffix : '')}
- {isPercentage ? '%' : ''}
- {format === 'ms' ? 'ms' : ''}
- {/* Show percentage if max value is set */}
- {percentage !== null && !isMax && !isPercentage && (
- <span className="text-[11px] text-foreground-light mb-0.5">({percentage}%)</span>
- )}
- </span>
- </div>
- )
- }
- return (
- <div
- className={cn(
- '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',
- !isActiveHoveredChart && 'opacity-0'
- )}
- >
- <p className="text-foreground-light text-xs">{localTimeZone}</p>
- <p className="font-medium">{formatDateTime(timestamp, DateTimeFormats.FULL_SECONDS)}</p>
- <div className="grid gap-0">
- {[...payload].reverse().map((entry: any, index: number) => (
- <LabelItem key={`${entry.name}-${index}`} entry={entry} />
- ))}
- {active && showTotal && (
- <div className="flex md:flex-col gap-1 md:gap-0 text-foreground mt-1">
- <span className="grow text-foreground-lighter">Total</span>
- <div className="flex items-end gap-1">
- <span className="text-base">
- {isPercentage
- ? formatPercentage(total as number, valuePrecision)
- : formatNumeric(total as number) +
- (!isPercentage && format !== 'ms' ? byteUnitSuffix : '')}
- {format === 'ms' ? 'ms' : ''}
- </span>
- {maxValueAttribute &&
- hasFiniteMaxValue &&
- !isPercentage &&
- !isNaN((total as number) / maxValue) &&
- isFinite((total as number) / maxValue) && (
- <span className="text-[11px] text-foreground-light mb-0.5">
- ({(((total as number) / maxValue) * 100).toFixed(1)}%)
- </span>
- )}
- </div>
- </div>
- )}
- </div>
- </div>
- )
- }
- return null
- }
- interface CustomLabelProps {
- payload?: any[]
- attributes?: MultiAttribute[]
- showMaxValue?: boolean
- onLabelHover?: (label: string | null) => void
- onToggleAttribute?: (attribute: string, options?: { exclusive?: boolean }) => void
- hiddenAttributes?: Set<string>
- }
- export const CustomLabel = ({
- payload,
- attributes,
- showMaxValue,
- onLabelHover,
- onToggleAttribute,
- hiddenAttributes,
- }: CustomLabelProps) => {
- const items = payload ?? []
- const maxValueAttribute = isMaxAttribute(attributes)
- const [, setHoveredLabel] = useState<string | null>(null)
- const handleMouseEnter = (label: string) => {
- setHoveredLabel(label)
- onLabelHover?.(label)
- }
- const handleMouseLeave = () => {
- setHoveredLabel(null)
- onLabelHover?.(null)
- }
- const getIcon = (name: string, color: string) => {
- switch (name === maxValueAttribute?.attribute) {
- case true:
- return <MaxConnectionsIcon />
- default:
- return <CustomIcon color={color} />
- }
- }
- const LabelItem = ({ entry }: { entry: any }) => {
- const attribute = attributes?.find((a) => a.attribute === entry.name)
- const isMax = entry.name === maxValueAttribute?.attribute
- const isHidden = hiddenAttributes?.has(entry.name)
- const color = isHidden ? 'gray' : entry.color
- const Label = () => (
- <div className="flex items-center gap-1">
- {getIcon(entry.name, color)}
- <span className={cn('text-nowrap text-foreground-lighter', isHidden && 'opacity-50')}>
- {attribute?.label || entry.name}
- </span>
- </div>
- )
- if (!showMaxValue && isMax) return null
- return (
- <button
- key={entry.name}
- className="flex md:flex-col gap-1 md:gap-0 w-fit text-foreground rounded-lg hover:bg-background-overlay-hover"
- onMouseOver={() => handleMouseEnter(entry.name)}
- onMouseOutCapture={handleMouseLeave}
- onClick={(e) => onToggleAttribute?.(entry.name, { exclusive: e.metaKey || e.ctrlKey })}
- >
- {!!attribute?.tooltip ? (
- <Tooltip>
- <TooltipTrigger className="p-1.5">
- <Label />
- </TooltipTrigger>
- <TooltipContent sideOffset={6} side="bottom" align="center" className="max-w-[250px]">
- {attribute.tooltip}
- </TooltipContent>
- </Tooltip>
- ) : (
- <Label />
- )}
- </button>
- )
- }
- return (
- <div className="relative z-10 mx-auto flex flex-col items-center gap-1 text-xs w-full">
- <div className="flex flex-wrap items-center justify-center gap-2">
- <TooltipProvider delayDuration={800}>
- {items?.map((entry, index) => (
- <LabelItem key={`${entry.name}-${index}`} entry={entry} />
- ))}
- </TooltipProvider>
- </div>
- </div>
- )
- }
|