ChartBlock.tsx368 lines · main
| 1 | import dayjs from 'dayjs' |
| 2 | import { Activity, BarChartIcon, Loader2 } from 'lucide-react' |
| 3 | import { useRouter } from 'next/router' |
| 4 | import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' |
| 5 | import { Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts' |
| 6 | import { ChartContainer, ChartTooltip, ChartTooltipContent, WarningIcon } from 'ui' |
| 7 | |
| 8 | import { METRIC_THRESHOLDS } from './ReportBlock.constants' |
| 9 | import { ReportBlockContainer } from './ReportBlockContainer' |
| 10 | import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' |
| 11 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 12 | import { timestampFormatter } from '@/components/ui/Charts/Charts.utils' |
| 13 | import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder' |
| 14 | import { |
| 15 | checkHasNonPositiveValues, |
| 16 | computeYAxisWidth, |
| 17 | formatLogTick, |
| 18 | formatYAxisTick, |
| 19 | } from '@/components/ui/QueryBlock/QueryBlock.utils' |
| 20 | import { AnalyticsInterval } from '@/data/analytics/constants' |
| 21 | import { mapMultiResponseToAnalyticsData } from '@/data/analytics/infra-monitoring-queries' |
| 22 | import { |
| 23 | InfraMonitoringAttribute, |
| 24 | useInfraMonitoringAttributesQuery, |
| 25 | } from '@/data/analytics/infra-monitoring-query' |
| 26 | import { |
| 27 | ProjectDailyStatsAttribute, |
| 28 | useProjectDailyStatsQuery, |
| 29 | } from '@/data/analytics/project-daily-stats-query' |
| 30 | import { METRICS } from '@/lib/constants/metrics' |
| 31 | import { useFormatDateTime } from '@/lib/datetime' |
| 32 | import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' |
| 33 | import type { Dashboards } from '@/types' |
| 34 | |
| 35 | interface ChartBlockProps { |
| 36 | label: string |
| 37 | attribute: string |
| 38 | provider: 'infra-monitoring' | 'daily-stats' |
| 39 | startDate: string |
| 40 | endDate: string |
| 41 | interval?: AnalyticsInterval |
| 42 | defaultChartStyle?: 'bar' | 'line' |
| 43 | defaultLogScale?: boolean |
| 44 | isLoading?: boolean |
| 45 | actions?: ReactNode |
| 46 | maxHeight?: number |
| 47 | onUpdateChartConfig?: ({ |
| 48 | chart, |
| 49 | chartConfig, |
| 50 | }: { |
| 51 | chart?: Partial<Dashboards.Chart> |
| 52 | chartConfig?: Partial<ChartConfig> |
| 53 | }) => void |
| 54 | } |
| 55 | |
| 56 | export const ChartBlock = ({ |
| 57 | label, |
| 58 | attribute, |
| 59 | provider, |
| 60 | startDate, |
| 61 | endDate, |
| 62 | interval = '1d', |
| 63 | defaultChartStyle = 'bar', |
| 64 | defaultLogScale = false, |
| 65 | isLoading = false, |
| 66 | actions, |
| 67 | maxHeight, |
| 68 | onUpdateChartConfig, |
| 69 | }: ChartBlockProps) => { |
| 70 | const router = useRouter() |
| 71 | const { ref } = router.query |
| 72 | |
| 73 | const state = useDatabaseSelectorStateSnapshot() |
| 74 | const [chartStyle, setChartStyle] = useState<string>(defaultChartStyle) |
| 75 | const logScale = useMemo(() => defaultLogScale, [defaultLogScale]) |
| 76 | const [latestValue, setLatestValue] = useState<string | undefined>() |
| 77 | const formatChartDate = useFormatDateTime() |
| 78 | const formatTooltipDate = useCallback( |
| 79 | (value: string | number, format: string) => |
| 80 | /^\d{4}-\d{2}-\d{2}$/.test(String(value)) |
| 81 | ? timestampFormatter(String(value), format, true) |
| 82 | : formatChartDate(value, format), |
| 83 | [formatChartDate] |
| 84 | ) |
| 85 | |
| 86 | const databaseIdentifier = state.selectedDatabaseId |
| 87 | |
| 88 | const { |
| 89 | data: dailyStatsData, |
| 90 | isFetching: isFetchingDailyStats, |
| 91 | isPending: isLoadingDailyStats, |
| 92 | } = useProjectDailyStatsQuery( |
| 93 | { |
| 94 | projectRef: ref as string, |
| 95 | attribute: attribute as ProjectDailyStatsAttribute, |
| 96 | startDate: dayjs(startDate).format('YYYY-MM-DD'), |
| 97 | endDate: dayjs(endDate).format('YYYY-MM-DD'), |
| 98 | }, |
| 99 | { enabled: provider === 'daily-stats' } |
| 100 | ) |
| 101 | |
| 102 | const { |
| 103 | data: infraMonitoringRawData, |
| 104 | isFetching: isFetchingInfraMonitoring, |
| 105 | isPending: isLoadingInfraMonitoring, |
| 106 | } = useInfraMonitoringAttributesQuery( |
| 107 | { |
| 108 | projectRef: ref as string, |
| 109 | attributes: [attribute as InfraMonitoringAttribute], |
| 110 | startDate, |
| 111 | endDate, |
| 112 | interval: interval as AnalyticsInterval, |
| 113 | databaseIdentifier, |
| 114 | }, |
| 115 | { enabled: provider === 'infra-monitoring' } |
| 116 | ) |
| 117 | |
| 118 | const infraMonitoringData = useMemo(() => { |
| 119 | if (!infraMonitoringRawData) return undefined |
| 120 | const mapped = mapMultiResponseToAnalyticsData(infraMonitoringRawData, [ |
| 121 | attribute as InfraMonitoringAttribute, |
| 122 | ]) |
| 123 | return mapped[attribute] |
| 124 | }, [infraMonitoringRawData, attribute]) |
| 125 | |
| 126 | const chartData = |
| 127 | provider === 'infra-monitoring' |
| 128 | ? infraMonitoringData |
| 129 | : provider === 'daily-stats' |
| 130 | ? dailyStatsData |
| 131 | : undefined |
| 132 | |
| 133 | const isFetching = |
| 134 | provider === 'infra-monitoring' |
| 135 | ? isFetchingInfraMonitoring |
| 136 | : provider === 'daily-stats' |
| 137 | ? isFetchingDailyStats |
| 138 | : false |
| 139 | |
| 140 | const loading = |
| 141 | isLoading || |
| 142 | attribute.startsWith('new_snippet_') || |
| 143 | (provider === 'infra-monitoring' |
| 144 | ? isLoadingInfraMonitoring |
| 145 | : provider === 'daily-stats' |
| 146 | ? isLoadingDailyStats |
| 147 | : isLoading) |
| 148 | |
| 149 | const metric = METRICS.find((x) => x.key === attribute) |
| 150 | const metricLabel = metric?.label ?? attribute |
| 151 | |
| 152 | const getCellColor = (attribute: string, value: number) => { |
| 153 | const threshold = METRIC_THRESHOLDS[attribute as keyof typeof METRIC_THRESHOLDS] |
| 154 | if (!threshold) return 'hsl(var(--chart-1))' |
| 155 | if (threshold.check === 'gt') { |
| 156 | return value >= threshold.danger |
| 157 | ? 'hsl(var(--chart-destructive))' |
| 158 | : value >= threshold.warning |
| 159 | ? 'hsl(var(--chart-warning))' |
| 160 | : 'hsl(var(--chart-1))' |
| 161 | } else { |
| 162 | return value <= threshold.danger |
| 163 | ? 'hsl(var(--chart-destructive))' |
| 164 | : value <= threshold.warning |
| 165 | ? 'hsl(var(--chart-warning))' |
| 166 | : 'hsl(var(--chart-1))' |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | const isPercentage = chartData?.format === '%' |
| 171 | const data = (chartData?.data ?? []).map((x: any) => { |
| 172 | const value = isPercentage ? x[attribute] : x[attribute] |
| 173 | const color = getCellColor(attribute, x[attribute]) |
| 174 | return { |
| 175 | ...x, |
| 176 | period_start: dayjs(x.period_start).utc().format('YYYY-MM-DD'), |
| 177 | [attribute]: value, |
| 178 | [metricLabel]: value, |
| 179 | fill: color, |
| 180 | stroke: color, |
| 181 | } |
| 182 | }) |
| 183 | |
| 184 | const hasNonPositiveValues = useMemo(() => { |
| 185 | if (!logScale || !data.length) return false |
| 186 | return checkHasNonPositiveValues(data, metricLabel) |
| 187 | }, [logScale, data, metricLabel]) |
| 188 | |
| 189 | const effectiveLogScale = logScale && !hasNonPositiveValues |
| 190 | |
| 191 | const yAxisWidth = computeYAxisWidth(data, metricLabel, { |
| 192 | isLogScale: effectiveLogScale, |
| 193 | isPercentage, |
| 194 | }) |
| 195 | |
| 196 | const getInitialHighlightedValue = useCallback(() => { |
| 197 | if (!chartData?.data?.length) return undefined |
| 198 | const lastDataPoint = chartData.data[chartData.data.length - 1] |
| 199 | const value = lastDataPoint[attribute] |
| 200 | return isPercentage |
| 201 | ? `${typeof value === 'number' ? value.toFixed(1) : value}%` |
| 202 | : typeof value === 'number' |
| 203 | ? value.toLocaleString() |
| 204 | : value |
| 205 | }, [chartData?.data, chartData?.format, attribute]) |
| 206 | |
| 207 | useEffect(() => { |
| 208 | if (defaultChartStyle) setChartStyle(defaultChartStyle) |
| 209 | }, [defaultChartStyle]) |
| 210 | |
| 211 | useEffect(() => { |
| 212 | setLatestValue(getInitialHighlightedValue()) |
| 213 | }, [chartData, getInitialHighlightedValue]) |
| 214 | |
| 215 | return ( |
| 216 | <ReportBlockContainer |
| 217 | draggable |
| 218 | showDragHandle |
| 219 | loading={isFetching} |
| 220 | icon={metric?.category?.icon('text-foreground-muted')} |
| 221 | label={label} |
| 222 | actions={ |
| 223 | <> |
| 224 | <ButtonTooltip |
| 225 | type="text" |
| 226 | size="tiny" |
| 227 | disabled={loading} |
| 228 | className="w-7 h-7" |
| 229 | icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />} |
| 230 | onClick={() => { |
| 231 | const style = chartStyle === 'bar' ? 'line' : 'bar' |
| 232 | if (onUpdateChartConfig) onUpdateChartConfig({ chart: { chart_type: style } }) |
| 233 | setChartStyle(style) |
| 234 | }} |
| 235 | tooltip={{ |
| 236 | content: { |
| 237 | side: 'bottom', |
| 238 | className: 'max-w-56 text-center', |
| 239 | text: `View as ${chartStyle === 'bar' ? 'line chart' : 'bar chart'}`, |
| 240 | }, |
| 241 | }} |
| 242 | /> |
| 243 | <ButtonTooltip |
| 244 | type={logScale ? 'default' : 'text'} |
| 245 | size="tiny" |
| 246 | disabled={loading} |
| 247 | className="h-7 px-1.5 font-mono text-[10px]" |
| 248 | icon={<span className="font-mono text-[10px] leading-none">Log</span>} |
| 249 | onClick={() => { |
| 250 | const next = !logScale |
| 251 | if (onUpdateChartConfig) onUpdateChartConfig({ chartConfig: { logScale: next } }) |
| 252 | }} |
| 253 | tooltip={{ |
| 254 | content: { |
| 255 | side: 'bottom', |
| 256 | className: 'max-w-56 text-center', |
| 257 | text: `Switch to ${logScale ? 'linear' : 'logarithmic'} scale`, |
| 258 | }, |
| 259 | }} |
| 260 | /> |
| 261 | {actions} |
| 262 | </> |
| 263 | } |
| 264 | > |
| 265 | {loading ? ( |
| 266 | <div className="flex grow w-full flex-col items-center justify-center gap-y-2 px-4"> |
| 267 | <Loader2 size={18} className="animate-spin text-border-strong" /> |
| 268 | <p className="text-xs text-foreground-lighter text-center">Loading data for {label}</p> |
| 269 | </div> |
| 270 | ) : chartData === undefined ? ( |
| 271 | <div className="flex grow w-full flex-col items-center justify-center gap-y-2 px-4"> |
| 272 | <WarningIcon /> |
| 273 | <p className="text-xs text-foreground-lighter text-center"> |
| 274 | Unable to load data for {label} |
| 275 | </p> |
| 276 | </div> |
| 277 | ) : data.length === 0 ? ( |
| 278 | <div className="flex grow w-full flex-col items-center justify-center gap-y-2"> |
| 279 | <NoDataPlaceholder |
| 280 | size="small" |
| 281 | className="border-0" |
| 282 | description="It may take up to 24 hours for data to refresh" |
| 283 | /> |
| 284 | </div> |
| 285 | ) : ( |
| 286 | <> |
| 287 | {latestValue && ( |
| 288 | <div className="pt-2 px-3 w-full text-left leading-tight"> |
| 289 | <span className="text-xs font-mono uppercase text-foreground-light"> |
| 290 | Most recently |
| 291 | </span> |
| 292 | <p className="text-lg text">{latestValue}</p> |
| 293 | </div> |
| 294 | )} |
| 295 | {hasNonPositiveValues && ( |
| 296 | <p className="px-3 pt-1 text-xs text-foreground-light"> |
| 297 | Log scale is unavailable because the data contains zero or negative values. |
| 298 | </p> |
| 299 | )} |
| 300 | <ChartContainer |
| 301 | className="w-full aspect-auto px-3 py-2" |
| 302 | style={{ |
| 303 | height: maxHeight ? `${maxHeight}px` : undefined, |
| 304 | minHeight: maxHeight ? `${maxHeight}px` : undefined, |
| 305 | }} |
| 306 | > |
| 307 | {chartStyle === 'bar' ? ( |
| 308 | <BarChart accessibilityLayer margin={{ left: 0, right: 0 }} data={data}> |
| 309 | <CartesianGrid vertical={false} /> |
| 310 | <XAxis |
| 311 | dataKey="period_start" |
| 312 | tickLine={false} |
| 313 | axisLine={false} |
| 314 | tickMargin={8} |
| 315 | minTickGap={32} |
| 316 | /> |
| 317 | <YAxis |
| 318 | scale={effectiveLogScale ? 'log' : 'auto'} |
| 319 | domain={effectiveLogScale ? [1, 'auto'] : isPercentage ? [0, 100] : undefined} |
| 320 | allowDataOverflow={effectiveLogScale} |
| 321 | width={yAxisWidth} |
| 322 | tickFormatter={effectiveLogScale ? formatLogTick : formatYAxisTick} |
| 323 | /> |
| 324 | <ChartTooltip |
| 325 | content={ |
| 326 | <ChartTooltipContent |
| 327 | className="min-w-[200px]" |
| 328 | labelSuffix={isPercentage ? '%' : ''} |
| 329 | labelFormatter={(x) => formatTooltipDate(x as string | number, 'DD MMM YYYY')} |
| 330 | /> |
| 331 | } |
| 332 | /> |
| 333 | <Bar dataKey={metricLabel} radius={[2, 2, 1, 1]} /> |
| 334 | </BarChart> |
| 335 | ) : ( |
| 336 | <LineChart accessibilityLayer margin={{ left: 0, right: 0 }} data={data}> |
| 337 | <CartesianGrid vertical={false} /> |
| 338 | <XAxis |
| 339 | dataKey="period_start" |
| 340 | tickLine={false} |
| 341 | axisLine={false} |
| 342 | tickMargin={8} |
| 343 | minTickGap={32} |
| 344 | /> |
| 345 | <YAxis |
| 346 | scale={effectiveLogScale ? 'log' : 'auto'} |
| 347 | domain={effectiveLogScale ? [1, 'auto'] : isPercentage ? [0, 100] : undefined} |
| 348 | allowDataOverflow={effectiveLogScale} |
| 349 | width={yAxisWidth} |
| 350 | tickFormatter={effectiveLogScale ? formatLogTick : formatYAxisTick} |
| 351 | /> |
| 352 | <ChartTooltip |
| 353 | content={ |
| 354 | <ChartTooltipContent |
| 355 | labelSuffix={chartData?.format === '%' ? '%' : ''} |
| 356 | labelFormatter={(x) => formatTooltipDate(x as string | number, 'DD MMM YYYY')} |
| 357 | /> |
| 358 | } |
| 359 | /> |
| 360 | <Line dataKey={metricLabel} stroke="hsl(var(--chart-1))" radius={4} /> |
| 361 | </LineChart> |
| 362 | )} |
| 363 | </ChartContainer> |
| 364 | </> |
| 365 | )} |
| 366 | </ReportBlockContainer> |
| 367 | ) |
| 368 | } |