From a536d24c4fa1f623d84b196c2517162759f31ca3 Mon Sep 17 00:00:00 2001 From: Aditya Pimpalkar Date: Thu, 5 Feb 2026 13:35:12 +0530 Subject: [PATCH 1/6] feat: add pie chart as dashboard tile --- packages/app/src/ChartUtils.tsx | 6 + packages/app/src/DBDashboardPage.tsx | 9 + .../src/components/DBEditTimeChartForm.tsx | 22 +- packages/app/src/components/DBPieChart.tsx | 199 ++++++++++++++++++ packages/common-utils/src/types.ts | 1 + 5 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/components/DBPieChart.tsx 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 449254d990..423625f690 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 9742f156c3..e5147bf341 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, @@ -106,6 +107,7 @@ import ChartDisplaySettingsDrawer, { ChartConfigDisplaySettings, } from './ChartDisplaySettingsDrawer'; import DBNumberChart from './DBNumberChart'; +import { DBPieChart } from './DBPieChart'; import DBSqlRowTableWithSideBar from './DBSqlRowTableWithSidebar'; import { CheckBoxControlled, @@ -592,6 +594,8 @@ export default function EditTimeChartForm({ return 'markdown'; case DisplayType.Table: return 'table'; + case DisplayType.Pie: + return 'pie'; case DisplayType.Number: return 'number'; default: @@ -605,7 +609,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 [ @@ -965,6 +971,12 @@ export default function EditTimeChartForm({ > Number + } + > + Pie + } @@ -1429,6 +1441,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, + ]); + + const pieChartData = useMemo(() => { + if (!data) return []; + if (data.data.length > 1) return []; + + 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], + })); + }, [data, aliasMap]); + + 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/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 5b1f23243c..8c81b9bd81 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', From aaa7d01de187b40ede7cc99ae1e2cd5e2ed17bf6 Mon Sep 17 00:00:00 2001 From: Aditya Pimpalkar Date: Fri, 6 Feb 2026 14:31:01 +0530 Subject: [PATCH 2/6] feat: add unit tests --- packages/app/src/components/DBPieChart.tsx | 8 +- .../components/__tests__/DBPieChart.test.tsx | 180 ++++++++++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/components/__tests__/DBPieChart.test.tsx diff --git a/packages/app/src/components/DBPieChart.tsx b/packages/app/src/components/DBPieChart.tsx index 1381addc03..ff5ee3856c 100644 --- a/packages/app/src/components/DBPieChart.tsx +++ b/packages/app/src/components/DBPieChart.tsx @@ -169,7 +169,13 @@ export const DBPieChart = ({ No data found within time range.
) : ( - + ({ + 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(); + }); +}); From beb9a989d0980c8cf05874b6772eef6398b3c3dd Mon Sep 17 00:00:00 2001 From: Aditya Pimpalkar Date: Wed, 11 Feb 2026 13:42:57 +0530 Subject: [PATCH 3/6] feat: add support for group by --- packages/app/src/components/DBPieChart.tsx | 86 +++++++++++++++++----- 1 file changed, 67 insertions(+), 19 deletions(-) diff --git a/packages/app/src/components/DBPieChart.tsx b/packages/app/src/components/DBPieChart.tsx index ff5ee3856c..f9d11ab209 100644 --- a/packages/app/src/components/DBPieChart.tsx +++ b/packages/app/src/components/DBPieChart.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import randomUUID from 'crypto-randomuuid'; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; -import { color } from 'storybook/theming'; import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse'; import { ChartConfigWithOptTimestamp } from '@hyperdx/common-utils/dist/types'; import { Box, Code, Flex, Text } from '@mantine/core'; @@ -111,25 +110,74 @@ export const DBPieChart = ({ 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) return []; - if (data.data.length > 1) return []; - - 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], - })); - }, [data, aliasMap]); + 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 ( From b56a0eae4e052c49a2689bbe32406500e10fad4c Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:26:08 -0500 Subject: [PATCH 4/6] fix: linting --- packages/app/tests/e2e/page-objects/DashboardPage.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 }); } From 14185fd19065634bb80c9a36bd33651fc2d87d99 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:28:01 -0500 Subject: [PATCH 5/6] fix: key uses label rather than random uuid. Has performance implications --- packages/app/src/components/DBPieChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/DBPieChart.tsx b/packages/app/src/components/DBPieChart.tsx index f9d11ab209..1a30c192a5 100644 --- a/packages/app/src/components/DBPieChart.tsx +++ b/packages/app/src/components/DBPieChart.tsx @@ -240,7 +240,7 @@ export const DBPieChart = ({ legendType="none" > {pieChartData.map(entry => ( - + ))} From dcf28df590f580e240e94664b9a1d93f92f9310d Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:29:41 -0500 Subject: [PATCH 6/6] add changeset --- .changeset/healthy-squids-repair.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/healthy-squids-repair.md 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