mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: add pie chart as dashboard tile (#1704)
Co-authored-by: Aaron Knudtson <87577305+knudtty@users.noreply.github.com>
This commit is contained in:
parent
185d4e4008
commit
051276fc17
8 changed files with 479 additions and 4 deletions
6
.changeset/healthy-squids-repair.md
Normal file
6
.changeset/healthy-squids-repair.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": minor
|
||||
"@hyperdx/app": minor
|
||||
---
|
||||
|
||||
feat: pie chart now available for chart visualization
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
253
packages/app/src/components/DBPieChart.tsx
Normal file
253
packages/app/src/components/DBPieChart.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
180
packages/app/src/components/__tests__/DBPieChart.test.tsx
Normal file
180
packages/app/src/components/__tests__/DBPieChart.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export enum DisplayType {
|
|||
Line = 'line',
|
||||
StackedBar = 'stacked_bar',
|
||||
Table = 'table',
|
||||
Pie = 'pie',
|
||||
Number = 'number',
|
||||
Search = 'search',
|
||||
Heatmap = 'heatmap',
|
||||
|
|
|
|||
Loading…
Reference in a new issue