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',