diff --git a/.changeset/healthy-squids-repair.md b/.changeset/healthy-squids-repair.md new file mode 100644 index 0000000000..084150b7b8 --- /dev/null +++ b/.changeset/healthy-squids-repair.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/common-utils": minor +"@hyperdx/app": minor +--- + +feat: pie chart now available for chart visualization diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index 2ecd0b8b46..1aceab1d41 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -1114,6 +1114,12 @@ export function convertToNumberChartConfig( return omit(config, ['granularity', 'groupBy']); } +export function convertToPieChartConfig( + config: ChartConfigWithOptTimestamp, +): ChartConfigWithOptTimestamp { + return omit(config, ['granularity']); +} + export function convertToTableChartConfig( config: ChartConfigWithOptTimestamp, ): ChartConfigWithOptTimestamp { diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index a3e4dc4e7d..ad9b429d61 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -79,6 +79,7 @@ import { } from '@/dashboard'; import ChartContainer from './components/charts/ChartContainer'; +import { DBPieChart } from './components/DBPieChart'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import OnboardingModal from './components/OnboardingModal'; import { Tags } from './components/Tags'; @@ -395,6 +396,14 @@ const Tile = forwardRef( config={queriedConfig} /> )} + {queriedConfig?.displayType === DisplayType.Pie && ( + + )} {/* Markdown charts may not have queriedConfig, if source is not set */} {(queriedConfig?.displayType === DisplayType.Markdown || (!queriedConfig && diff --git a/packages/app/src/components/DBEditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm.tsx index b9912f6e3f..356d29f34c 100644 --- a/packages/app/src/components/DBEditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm.tsx @@ -51,6 +51,7 @@ import { IconArrowUp, IconBell, IconChartLine, + IconChartPie, IconCirclePlus, IconCode, IconDotsVertical, @@ -110,6 +111,7 @@ import ChartDisplaySettingsDrawer, { ChartConfigDisplaySettings, } from './ChartDisplaySettingsDrawer'; import DBNumberChart from './DBNumberChart'; +import { DBPieChart } from './DBPieChart'; import DBSqlRowTableWithSideBar from './DBSqlRowTableWithSidebar'; import { CheckBoxControlled, @@ -656,6 +658,8 @@ export default function EditTimeChartForm({ return 'markdown'; case DisplayType.Table: return 'table'; + case DisplayType.Pie: + return 'pie'; case DisplayType.Number: return 'number'; default: @@ -669,7 +673,9 @@ export default function EditTimeChartForm({ } }, [displayType, setValue]); - const showGeneratedSql = ['table', 'time', 'number'].includes(activeTab); // Whether to show the generated SQL preview + const showGeneratedSql = ['table', 'time', 'number', 'pie'].includes( + activeTab, + ); // Whether to show the generated SQL preview const showSampleEvents = tableSource?.kind !== SourceKind.Metric; const [ @@ -1030,6 +1036,12 @@ export default function EditTimeChartForm({ > Number + } + > + Pie + } @@ -1494,6 +1506,14 @@ export default function EditTimeChartForm({ /> )} + {queryReady && dbTimeChartConfig != null && activeTab === 'pie' && ( +
+ +
+ )} {queryReady && queriedConfig != null && activeTab === 'number' && (
{ + const { data: source } = useSource({ id: config.source }); + + const queriedConfig = useMemo(() => { + return convertToPieChartConfig(config); + }, [config]); + + const { data: mvOptimizationData } = + useMVOptimizationExplanation(queriedConfig); + + const { data, isLoading, isError, error } = useQueriedChartConfig( + queriedConfig, + { + placeholderData: (prev: any) => prev, + queryKey: [queryKeyPrefix, queriedConfig], + enabled, + }, + ); + + // Returns an array of aliases, so we can check if something is using an alias + const aliasMap = useMemo(() => { + // If the config.select is a string, we can't infer this. + // One day, we could potentially run this through chSqlToAliasMap but AST parsing + // doesn't work for most DBTableChart queries. + if (typeof config.select === 'string') { + return []; + } + return config.select.reduce((acc, select) => { + if (select.alias) { + acc.push(select.alias); + } + return acc; + }, [] as string[]); + }, [config.select]); + + const toolbarItemsMemo = useMemo(() => { + const allToolbarItems = []; + + if (toolbarPrefix && toolbarPrefix.length > 0) { + allToolbarItems.push(...toolbarPrefix); + } + + if (source && showMVOptimizationIndicator) { + allToolbarItems.push( + , + ); + } + + const dateRangeIndicator = buildMVDateRangeIndicator({ + mvOptimizationData, + originalDateRange: queriedConfig.dateRange, + }); + + if (dateRangeIndicator) { + allToolbarItems.push(dateRangeIndicator); + } + + if (toolbarSuffix && toolbarSuffix.length > 0) { + allToolbarItems.push(...toolbarSuffix); + } + + return allToolbarItems; + }, [ + toolbarPrefix, + toolbarSuffix, + source, + showMVOptimizationIndicator, + mvOptimizationData, + queriedConfig, + ]); + + // Extract group column names from groupBy config + const groupByKeys = useMemo(() => { + if (!queriedConfig.groupBy) return []; + + if (typeof queriedConfig.groupBy === 'string') { + return queriedConfig.groupBy.split(',').map(v => v.trim()); + } + + return queriedConfig.groupBy.map(g => + typeof g === 'string' ? g : g.valueExpression, + ); + }, [queriedConfig.groupBy]); + + const pieChartData = useMemo(() => { + if (!data || data.data.length === 0) return []; + + if (groupByKeys.length > 0 && data.data.length > 0) { + const groupColumnSet = new Set(groupByKeys); + + return data.data.map((row, index) => { + const label = + groupByKeys.length === 1 + ? String(row[groupByKeys[0]]) + : groupByKeys.map(key => row[key]).join(' - '); + + let totalValue = 0; + for (const key in row) { + if (!groupColumnSet.has(key)) { + const numValue = parseFloat(row[key]); + if (!isNaN(numValue)) { + totalValue += numValue; + } + } + } + + return { + label, + value: totalValue, + color: + index >= COLORS.length + ? // Source - https://stackoverflow.com/a/5092872 + '#000000'.replace(/0/g, () => { + return (~~(Math.random() * 16)).toString(16); + }) + : COLORS[index], + }; + }); + } + + if (data.data.length === 1) { + const queryData = data.data[0]; + + return Object.keys(queryData).map((key, index) => ({ + // If it's an alias, wrap in quotes to support a variety of formats (ex "Time (ms)", "Req/s", etc) + label: aliasMap.includes(key) ? `${key}` : key, + value: parseFloat(queryData[key]), + color: + index >= COLORS.length + ? // Source - https://stackoverflow.com/a/5092872 + '#000000'.replace(/0/g, () => { + return (~~(Math.random() * 16)).toString(16); + }) + : COLORS[index], + })); + } + + return []; + }, [data, aliasMap, groupByKeys]); + + return ( + + {isLoading && !data ? ( +
+ Loading Chart Data... +
+ ) : isError && error ? ( +
+ + Error loading chart, please check your query or try again later. + + + + Error Message: + + + {error.message} + + {error instanceof ClickHouseQueryError && ( + <> + + Sent Query: + + + + )} + +
+ ) : data?.data.length === 0 ? ( +
+ No data found within time range. +
+ ) : ( + + + + + {pieChartData.map(entry => ( + + ))} + + + + + + )} +
+ ); +}; diff --git a/packages/app/src/components/__tests__/DBPieChart.test.tsx b/packages/app/src/components/__tests__/DBPieChart.test.tsx new file mode 100644 index 0000000000..37e350014a --- /dev/null +++ b/packages/app/src/components/__tests__/DBPieChart.test.tsx @@ -0,0 +1,180 @@ +import { use } from 'react'; +import { screen } from '@testing-library/react'; + +import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation'; +import { useSource } from '@/source'; + +import DateRangeIndicator from '../charts/DateRangeIndicator'; +import { DBPieChart } from '../DBPieChart'; +import MVOptimizationIndicator from '../MaterializedViews/MVOptimizationIndicator'; + +jest.mock('@/hooks/useChartConfig', () => ({ + useQueriedChartConfig: jest.fn(), +})); + +jest.mock('@/hooks/useMVOptimizationExplanation', () => ({ + useMVOptimizationExplanation: jest.fn().mockReturnValue({ + data: undefined, + isLoading: false, + isPlaceholderData: false, + }), +})); + +jest.mock('@/source', () => ({ + useSource: jest.fn().mockReturnValue({ data: null }), +})); + +jest.mock('../MaterializedViews/MVOptimizationIndicator', () => + jest.fn(() => null), +); + +jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null)); + +describe('DBPieChart', () => { + const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock; + + const baseTestConfig = { + dateRange: [new Date(), new Date()] as [Date, Date], + from: { databaseName: 'test', tableName: 'test' }, + timestampValueExpression: 'timestamp', + connection: 'test-connection', + select: '', + where: '', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseQueriedChartConfig.mockReturnValue({ + data: { data: [{ test1: 1234, test2: 5678 }] }, + isLoading: false, + isError: false, + }); + }); + + it('handles loading state correctly', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + + renderWithMantine(); + expect(screen.getByText('Loading Chart Data...')).toBeInTheDocument(); + }); + + it('handles error state correctly', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('Test error'), + }); + + renderWithMantine(); + expect(screen.getByText(/Error loading chart/)).toBeInTheDocument(); + }); + + it('should render pie chart correctly', () => { + renderWithMantine(); + expect(screen.getByTestId('pie-chart-container')).toBeInTheDocument(); + }); + + it('passes the same config to useMVOptimizationExplanation, useQueriedChartConfig, and MVOptimizationIndicator', () => { + // Mock useSource to return a source so MVOptimizationIndicator is rendered + jest.mocked(useSource).mockReturnValue({ + data: { id: 'test-source', name: 'Test Source' }, + } as any); + + renderWithMantine(); + + // Get the config that was passed to useMVOptimizationExplanation + expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled(); + const mvOptExplanationConfig = jest.mocked(useMVOptimizationExplanation) + .mock.calls[0][0]; + + // Get the config that was passed to useQueriedChartConfig + expect(jest.mocked(useQueriedChartConfig)).toHaveBeenCalled(); + const queriedChartConfig = jest.mocked(useQueriedChartConfig).mock + .calls[0][0]; + + // Get the config that was passed to MVOptimizationIndicator + expect(jest.mocked(MVOptimizationIndicator)).toHaveBeenCalled(); + const indicatorConfig = jest.mocked(MVOptimizationIndicator).mock + .calls[0][0].config; + + // All three should receive the same config object reference + expect(mvOptExplanationConfig).toBe(queriedChartConfig); + expect(queriedChartConfig).toBe(indicatorConfig); + expect(mvOptExplanationConfig).toBe(indicatorConfig); + }); + + it('renders DateRangeIndicator when MV optimization returns a different date range', () => { + const originalStartDate = new Date('2024-01-01T00:00:30Z'); + const originalEndDate = new Date('2024-01-01T01:30:45Z'); + const alignedStartDate = new Date('2024-01-01T00:00:00Z'); + const alignedEndDate = new Date('2024-01-01T02:00:00Z'); + + const config = { + ...baseTestConfig, + dateRange: [originalStartDate, originalEndDate] as [Date, Date], + }; + + // Mock useMVOptimizationExplanation to return an optimized config with aligned date range + jest.mocked(useMVOptimizationExplanation).mockReturnValue({ + data: { + optimizedConfig: { + ...config, + dateRange: [alignedStartDate, alignedEndDate] as [Date, Date], + }, + explanations: [ + { + success: true, + mvConfig: { + minGranularity: '1 minute', + tableName: 'metrics_rollup_1m', + }, + }, + ], + }, + isLoading: false, + isPlaceholderData: false, + } as any); + + renderWithMantine(); + + // Verify DateRangeIndicator was called + expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled(); + + // Verify it was called with the correct props + const dateRangeIndicatorCall = + jest.mocked(DateRangeIndicator).mock.calls[0][0]; + expect(dateRangeIndicatorCall.originalDateRange).toEqual([ + originalStartDate, + originalEndDate, + ]); + expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([ + alignedStartDate, + alignedEndDate, + ]); + expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute'); + }); + + it('does not render DateRangeIndicator when MV optimization has no optimized date range', () => { + // Mock useMVOptimizationExplanation to return data without an optimized config + jest.mocked(useMVOptimizationExplanation).mockReturnValue({ + data: { + optimizedConfig: undefined, + explanations: [], + }, + isLoading: false, + isPlaceholderData: false, + } as any); + + renderWithMantine(); + + // Verify DateRangeIndicator was not called + expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 7d88c8d8ea..94a6936a0f 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -30,13 +30,13 @@ export type TileConfig = { groupBy?: string; markdown?: string; }; - +type SeriesType = 'time' | 'number' | 'table' | 'search' | 'markdown' | 'pie'; /** * Series data structure for chart verification * Supports all chart types: time, number, table, search, markdown */ export type SeriesData = { - type: 'time' | 'number' | 'table' | 'search' | 'markdown'; + type: SeriesType; sourceId?: string; aggFn?: string; field?: string; @@ -409,7 +409,7 @@ export class DashboardPage { return this.page.locator('.cm-content').filter({ hasText: text }); } - getChartTypeTab(type: 'time' | 'table' | 'number' | 'search' | 'markdown') { + getChartTypeTab(type: SeriesType) { if (type === 'time') { return this.page.getByRole('tab', { name: /line/i }); } diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 2bfaf8c8b3..db848397dc 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -16,6 +16,7 @@ export enum DisplayType { Line = 'line', StackedBar = 'stacked_bar', Table = 'table', + Pie = 'pie', Number = 'number', Search = 'search', Heatmap = 'heatmap',