feat: add pie chart as dashboard tile (#1704)

Co-authored-by: Aaron Knudtson <87577305+knudtty@users.noreply.github.com>
This commit is contained in:
Aditya Pimpalkar 2026-02-20 21:30:58 +05:30 committed by GitHub
parent 185d4e4008
commit 051276fc17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 479 additions and 4 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": minor
"@hyperdx/app": minor
---
feat: pie chart now available for chart visualization

View file

@ -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 {

View file

@ -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 && (
<DBPieChart
key={`${keyPrefix}-${chart.id}`}
title={title}
toolbarPrefix={toolbar}
config={queriedConfig}
/>
)}
{/* Markdown charts may not have queriedConfig, if source is not set */}
{(queriedConfig?.displayType === DisplayType.Markdown ||
(!queriedConfig &&

View file

@ -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
</Tabs.Tab>
<Tabs.Tab
value={DisplayType.Pie}
leftSection={<IconChartPie size={16} />}
>
Pie
</Tabs.Tab>
<Tabs.Tab
value={DisplayType.Search}
leftSection={<IconList size={16} />}
@ -1494,6 +1506,14 @@ export default function EditTimeChartForm({
/>
</div>
)}
{queryReady && dbTimeChartConfig != null && activeTab === 'pie' && (
<div className="flex-grow-1 d-flex flex-column" style={{ height: 400 }}>
<DBPieChart
config={dbTimeChartConfig}
showMVOptimizationIndicator={false}
/>
</div>
)}
{queryReady && queriedConfig != null && activeTab === 'number' && (
<div className="flex-grow-1 d-flex flex-column" style={{ height: 400 }}>
<DBNumberChart

View file

@ -0,0 +1,253 @@
import { useMemo } from 'react';
import randomUUID from 'crypto-randomuuid';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import { ChartConfigWithOptTimestamp } from '@hyperdx/common-utils/dist/types';
import { Box, Code, Flex, Text } from '@mantine/core';
import {
buildMVDateRangeIndicator,
convertToPieChartConfig,
} from '@/ChartUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { useSource } from '@/source';
import { COLORS } from '@/utils';
import ChartContainer from './charts/ChartContainer';
import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator';
import { SQLPreview } from './ChartSQLPreview';
export const DBPieChart = ({
config,
title,
enabled = true,
queryKeyPrefix,
showMVOptimizationIndicator = true,
toolbarPrefix,
toolbarSuffix,
}: {
config: ChartConfigWithOptTimestamp;
title?: React.ReactNode;
enabled?: boolean;
queryKeyPrefix?: string;
showMVOptimizationIndicator?: boolean;
toolbarPrefix?: React.ReactNode[];
toolbarSuffix?: React.ReactNode[];
}) => {
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(
<MVOptimizationIndicator
key="db-table-chart-mv-indicator"
config={queriedConfig}
source={source}
variant="icon"
/>,
);
}
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 (
<ChartContainer title={title} toolbarItems={toolbarItemsMemo}>
{isLoading && !data ? (
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
Loading Chart Data...
</div>
) : isError && error ? (
<div className="h-100 w-100 align-items-center justify-content-center text-muted overflow-scroll">
<Text ta="center" size="sm" mt="sm">
Error loading chart, please check your query or try again later.
</Text>
<Box mt="sm">
<Text my="sm" size="sm" ta="center">
Error Message:
</Text>
<Code
block
style={{
whiteSpace: 'pre-wrap',
}}
>
{error.message}
</Code>
{error instanceof ClickHouseQueryError && (
<>
<Text my="sm" size="sm" ta="center">
Sent Query:
</Text>
<SQLPreview data={error?.query} />
</>
)}
</Box>
</div>
) : data?.data.length === 0 ? (
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
No data found within time range.
</div>
) : (
<Flex
data-testid="pie-chart-container"
align="center"
justify="center"
h="100%"
style={{ flexGrow: 1 }}
>
<ResponsiveContainer
height="100%"
width="100%"
className={isLoading ? 'effect-pulse' : ''}
>
<PieChart>
<Pie
cx="50%"
cy="50%"
data={pieChartData}
dataKey="value"
fill="#8884d8"
nameKey="label"
legendType="none"
>
{pieChartData.map(entry => (
<Cell key={entry.label} fill={entry.color} stroke="none" />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</Flex>
)}
</ChartContainer>
);
};

View file

@ -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(<DBPieChart config={baseTestConfig} />);
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(<DBPieChart config={baseTestConfig} />);
expect(screen.getByText(/Error loading chart/)).toBeInTheDocument();
});
it('should render pie chart correctly', () => {
renderWithMantine(<DBPieChart config={baseTestConfig} />);
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(<DBPieChart config={baseTestConfig} />);
// 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(<DBPieChart config={config} />);
// 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(<DBPieChart config={baseTestConfig} />);
// Verify DateRangeIndicator was not called
expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled();
});
});

View file

@ -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 });
}

View file

@ -16,6 +16,7 @@ export enum DisplayType {
Line = 'line',
StackedBar = 'stacked_bar',
Table = 'table',
Pie = 'pie',
Number = 'number',
Search = 'search',
Heatmap = 'heatmap',