diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx index 9c5592727f7..abeab07ee05 100644 --- a/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx +++ b/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx @@ -1,11 +1,7 @@ -import dayjs from 'dayjs' -import { useRouter } from 'next/router' -import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' -import { ChartContainer, ChartTooltip, ChartTooltipContent } from 'ui' - import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder' +import { checkHasNonPositiveValues, formatLogTick } from 'components/ui/QueryBlock/QueryBlock.utils' import { AnalyticsInterval } from 'data/analytics/constants' import { mapMultiResponseToAnalyticsData } from 'data/analytics/infra-monitoring-queries' import { @@ -16,12 +12,16 @@ import { ProjectDailyStatsAttribute, useProjectDailyStatsQuery, } from 'data/analytics/project-daily-stats-query' +import dayjs from 'dayjs' import { METRICS } from 'lib/constants/metrics' import { Activity, BarChartIcon, Loader2 } from 'lucide-react' +import { useRouter } from 'next/router' +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' import { Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import type { Dashboards } from 'types' -import { WarningIcon } from 'ui' +import { ChartContainer, ChartTooltip, ChartTooltipContent, WarningIcon } from 'ui' + import { METRIC_THRESHOLDS } from './ReportBlock.constants' import { ReportBlockContainer } from './ReportBlockContainer' @@ -33,6 +33,7 @@ interface ChartBlockProps { endDate: string interval?: AnalyticsInterval defaultChartStyle?: 'bar' | 'line' + defaultLogScale?: boolean isLoading?: boolean actions?: ReactNode maxHeight?: number @@ -53,6 +54,7 @@ export const ChartBlock = ({ endDate, interval = '1d', defaultChartStyle = 'bar', + defaultLogScale = false, isLoading = false, actions, maxHeight, @@ -63,6 +65,7 @@ export const ChartBlock = ({ const state = useDatabaseSelectorStateSnapshot() const [chartStyle, setChartStyle] = useState(defaultChartStyle) + const logScale = useMemo(() => defaultLogScale, [defaultLogScale]) const [latestValue, setLatestValue] = useState() const databaseIdentifier = state.selectedDatabaseId @@ -163,6 +166,13 @@ export const ChartBlock = ({ } }) + const hasNonPositiveValues = useMemo(() => { + if (!logScale || !data.length) return false + return checkHasNonPositiveValues(data, metricLabel) + }, [logScale, data, metricLabel]) + + const effectiveLogScale = logScale && !hasNonPositiveValues + const getInitialHighlightedValue = useCallback(() => { if (!chartData?.data?.length) return undefined const lastDataPoint = chartData.data[chartData.data.length - 1] @@ -210,6 +220,24 @@ export const ChartBlock = ({ }, }} /> + Log} + onClick={() => { + const next = !logScale + if (onUpdateChartConfig) onUpdateChartConfig({ chartConfig: { logScale: next } }) + }} + tooltip={{ + content: { + side: 'bottom', + className: 'max-w-56 text-center', + text: `Switch to ${logScale ? 'linear' : 'logarithmic'} scale`, + }, + }} + /> {actions} } @@ -244,6 +272,11 @@ export const ChartBlock = ({

{latestValue}

)} + {hasNonPositiveValues && ( +

+ Log scale is unavailable because the data contains zero or negative values. +

+ )} - + - + { diff --git a/apps/studio/components/ui/QueryBlock/BlockViewConfiguration.tsx b/apps/studio/components/ui/QueryBlock/BlockViewConfiguration.tsx index 2580eea2296..c61080a29f3 100644 --- a/apps/studio/components/ui/QueryBlock/BlockViewConfiguration.tsx +++ b/apps/studio/components/ui/QueryBlock/BlockViewConfiguration.tsx @@ -1,6 +1,5 @@ -import { BarChart2, Settings2, Table } from 'lucide-react' - import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' +import { BarChart2, Settings2, Table } from 'lucide-react' import { Checkbox_Shadcn_, Label_Shadcn_, @@ -15,6 +14,7 @@ import { ToggleGroup, ToggleGroupItem, } from 'ui' + import { ButtonTooltip } from '../ButtonTooltip' interface BlockViewConfigurationProps { @@ -121,6 +121,19 @@ export const BlockViewConfiguration = ({ /> Cumulative + + + updateChartConfig({ + ...chartConfig, + logScale: !chartConfig?.logScale, + }) + } + /> + Log scale + )} diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx index ed3784f987c..c8a4bf9a38c 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx @@ -1,20 +1,19 @@ +import { ReportBlockContainer } from 'components/interfaces/Reports/ReportBlock/ReportBlockContainer' +import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' +import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' import dayjs from 'dayjs' import { Code, Play } from 'lucide-react' import { DragEvent, ReactNode, useEffect, useMemo, useRef, useState } from 'react' import { Bar, BarChart, CartesianGrid, Cell, Tooltip, XAxis, YAxis } from 'recharts' - -import { ReportBlockContainer } from 'components/interfaces/Reports/ReportBlock/ReportBlockContainer' -import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' -import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' - import { Badge, Button, ChartContainer, ChartTooltipContent, cn, CodeBlock } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import { ButtonTooltip } from '../ButtonTooltip' import { CHART_COLORS } from '../Charts/Charts.constants' import { SqlWarningAdmonition } from '../SqlWarningAdmonition' import { BlockViewConfiguration } from './BlockViewConfiguration' import { EditQueryButton } from './EditQueryButton' -import { getCumulativeResults } from './QueryBlock.utils' +import { checkHasNonPositiveValues, formatLogTick, getCumulativeResults } from './QueryBlock.utils' export const DEFAULT_CHART_CONFIG: ChartConfig = { type: 'bar', @@ -23,6 +22,7 @@ export const DEFAULT_CHART_CONFIG: ChartConfig = { yKey: '', showLabels: false, showGrid: false, + logScale: false, view: 'table', } @@ -68,7 +68,7 @@ export const QueryBlock = ({ onDragStart, }: QueryBlockProps) => { const [chartSettings, setChartSettings] = useState(chartConfig) - const { xKey, yKey, view = 'table' } = chartSettings + const { xKey, yKey, view = 'table', logScale = false } = chartSettings const [showSql, setShowSql] = useState(!results && !initialHideSql) const [focusDataIndex, setFocusDataIndex] = useState() @@ -105,6 +105,13 @@ export const QueryBlock = ({ ? getCumulativeResults({ rows: formattedQueryResult ?? [] }, chartSettings) : formattedQueryResult + const hasNonPositiveValues = useMemo(() => { + if (!logScale || !yKey || !chartData?.length) return false + return checkHasNonPositiveValues(chartData, yKey) + }, [logScale, yKey, chartData]) + + const effectiveLogScale = logScale && !hasNonPositiveValues + const getDateFormat = (key: any) => { const value = chartData?.[0]?.[key] || '' if (typeof value === 'number') return 'number' @@ -250,6 +257,11 @@ export const QueryBlock = ({ ) : (
+ {hasNonPositiveValues && ( +

+ Log scale is unavailable because the data contains zero or negative values. +

+ )} - + } /> {chartData?.map((_: any, index: number) => ( diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts b/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts index f3310842cc0..dc3cb9f4152 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts @@ -1,5 +1,16 @@ import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' +export const checkHasNonPositiveValues = (data: Record[], key: string): boolean => + data.some((row) => (row[key] as number) <= 0) + +export const formatLogTick = (value: number): string => { + if (value >= 1_000_000) + return `${(value / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 1 })}M` + if (value >= 1_000) + return `${(value / 1_000).toLocaleString(undefined, { maximumFractionDigits: 1 })}k` + return value.toLocaleString() +} + // Add helper function for cumulative results export const getCumulativeResults = (results: { rows: any[] }, config: ChartConfig) => { if (!results?.rows?.length) { diff --git a/apps/studio/tests/components/ui/QueryBlock/QueryBlock.utils.test.ts b/apps/studio/tests/components/ui/QueryBlock/QueryBlock.utils.test.ts new file mode 100644 index 00000000000..c168cbf9f48 --- /dev/null +++ b/apps/studio/tests/components/ui/QueryBlock/QueryBlock.utils.test.ts @@ -0,0 +1,158 @@ +import { + checkHasNonPositiveValues, + formatLogTick, + getCumulativeResults, +} from 'components/ui/QueryBlock/QueryBlock.utils' +import { describe, expect, it } from 'vitest' + +describe('checkHasNonPositiveValues', () => { + it('returns false for an empty array', () => { + expect(checkHasNonPositiveValues([], 'value')).toBe(false) + }) + + it('returns false when all values are positive', () => { + const data = [{ value: 1 }, { value: 2 }, { value: 100 }] + expect(checkHasNonPositiveValues(data, 'value')).toBe(false) + }) + + it('returns true when a value is zero', () => { + const data = [{ value: 1 }, { value: 0 }, { value: 3 }] + expect(checkHasNonPositiveValues(data, 'value')).toBe(true) + }) + + it('returns true when a value is negative', () => { + const data = [{ value: 5 }, { value: -1 }, { value: 3 }] + expect(checkHasNonPositiveValues(data, 'value')).toBe(true) + }) + + it('returns true when all values are non-positive', () => { + const data = [{ value: -5 }, { value: 0 }, { value: -1 }] + expect(checkHasNonPositiveValues(data, 'value')).toBe(true) + }) + + it('checks only the specified key', () => { + const data = [ + { x: -1, y: 5 }, + { x: 2, y: 10 }, + ] + expect(checkHasNonPositiveValues(data, 'y')).toBe(false) + expect(checkHasNonPositiveValues(data, 'x')).toBe(true) + }) + + it('returns false when key is absent (undefined cast to NaN is not <= 0)', () => { + const data = [{ value: 1 }, { value: 2 }] + expect(checkHasNonPositiveValues(data, 'missing')).toBe(false) + }) +}) + +describe('formatLogTick', () => { + it('formats values below 1,000 as plain locale strings', () => { + expect(formatLogTick(0)).toBe('0') + expect(formatLogTick(1)).toBe('1') + expect(formatLogTick(999)).toBe('999') + expect(formatLogTick(500)).toBe('500') + }) + + it('formats values >= 1,000 with a "k" suffix', () => { + expect(formatLogTick(1_000)).toBe('1k') + expect(formatLogTick(1_500)).toBe('1.5k') + expect(formatLogTick(10_000)).toBe('10k') + expect(formatLogTick(999_999)).toBe('1,000k') + }) + + it('formats values >= 1,000,000 with an "M" suffix', () => { + expect(formatLogTick(1_000_000)).toBe('1M') + expect(formatLogTick(1_500_000)).toBe('1.5M') + expect(formatLogTick(10_000_000)).toBe('10M') + expect(formatLogTick(1_234_567)).toBe('1.2M') + }) + + it('respects maximumFractionDigits of 1', () => { + // 1,050 → 1.05k, but max 1 decimal → "1.1k" (rounded) + expect(formatLogTick(1_050)).toBe('1.1k') + // 1,049 → 1.049k → "1k" (rounded down) + expect(formatLogTick(1_049)).toBe('1k') + }) +}) + +describe('getCumulativeResults', () => { + it('returns empty array when results are empty', () => { + expect( + getCumulativeResults( + { rows: [] }, + { type: 'bar', xKey: 'x', yKey: 'y', cumulative: false, showLabels: false, showGrid: false } + ) + ).toEqual([]) + }) + + it('returns empty array when results are undefined', () => { + expect( + getCumulativeResults(undefined as any, { + type: 'bar', + xKey: 'x', + yKey: 'y', + cumulative: false, + showLabels: false, + showGrid: false, + }) + ).toEqual([]) + }) + + it('accumulates yKey values across rows', () => { + const results = { + rows: [ + { x: 'a', y: 10 }, + { x: 'b', y: 20 }, + { x: 'c', y: 5 }, + ], + } + const config = { + type: 'bar' as const, + xKey: 'x', + yKey: 'y', + cumulative: true, + showLabels: false, + showGrid: false, + } + const output = getCumulativeResults(results, config) + expect(output).toEqual([ + { x: 'a', y: 10 }, + { x: 'b', y: 30 }, + { x: 'c', y: 35 }, + ]) + }) + + it('preserves other keys on each row', () => { + const results = { + rows: [ + { x: 'a', y: 1, label: 'foo' }, + { x: 'b', y: 2, label: 'bar' }, + ], + } + const config = { + type: 'bar' as const, + xKey: 'x', + yKey: 'y', + cumulative: true, + showLabels: false, + showGrid: false, + } + const output = getCumulativeResults(results, config) + expect(output[0]).toMatchObject({ x: 'a', y: 1, label: 'foo' }) + expect(output[1]).toMatchObject({ x: 'b', y: 3, label: 'bar' }) + }) + + it('handles a single row', () => { + const results = { rows: [{ x: 'a', y: 42 }] } + const config = { + type: 'bar' as const, + xKey: 'x', + yKey: 'y', + cumulative: true, + showLabels: false, + showGrid: false, + } + const output = getCumulativeResults(results, config) + expect(output).toEqual([{ x: 'a', y: 42 }]) + }) +})