ChartBlock.tsx 13 KB

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