mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Add raw sql line charts (#1866)
## Summary This PR adds support for raw-sql-based line charts. ### Screenshots or video https://github.com/user-attachments/assets/2c5cbdb6-491b-45fb-8864-0f45f34215b3 ### How to test locally or on Vercel This can be tested in the preview environment ### References - Linear Issue: HDX-3581 - Related PRs:
This commit is contained in:
parent
2efb8fdc52
commit
1e6fcf1c02
17 changed files with 814 additions and 276 deletions
6
.changeset/long-olives-relate.md
Normal file
6
.changeset/long-olives-relate.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add raw sql line charts
|
||||
|
|
@ -11,16 +11,19 @@ import {
|
|||
ResponseJSON,
|
||||
} from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { isMetricChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
|
||||
import { getAlignedDateRange } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
convertDateRangeToGranularityString,
|
||||
convertGranularityToSeconds,
|
||||
getAlignedDateRange,
|
||||
Granularity,
|
||||
} from '@hyperdx/common-utils/dist/core/utils';
|
||||
import { isBuilderChartConfig } from '@hyperdx/common-utils/dist/guards';
|
||||
import {
|
||||
AggregateFunction as AggFnV2,
|
||||
BuilderChartConfigWithDateRange,
|
||||
BuilderChartConfigWithOptTimestamp,
|
||||
BuilderSavedChartConfig,
|
||||
ChartConfigWithDateRange,
|
||||
ChartConfigWithOptDateRange,
|
||||
DisplayType,
|
||||
Filter,
|
||||
|
|
@ -132,41 +135,84 @@ export const isGranularity = (value: string): value is Granularity => {
|
|||
return Object.values(Granularity).includes(value as Granularity);
|
||||
};
|
||||
|
||||
export function convertToTimeChartConfig(
|
||||
config: BuilderChartConfigWithDateRange,
|
||||
function getTimeChartGranularity(
|
||||
granularity: string | undefined,
|
||||
dateRange: [Date, Date],
|
||||
) {
|
||||
const granularity =
|
||||
config.granularity === 'auto' || config.granularity == null
|
||||
? convertDateRangeToGranularityString(config.dateRange, 80)
|
||||
: config.granularity;
|
||||
return granularity === 'auto' || granularity == null
|
||||
? convertDateRangeToGranularityString(dateRange, 80)
|
||||
: granularity;
|
||||
}
|
||||
|
||||
const dateRange =
|
||||
config.alignDateRangeToGranularity === false
|
||||
? config.dateRange
|
||||
: getAlignedDateRange(config.dateRange, granularity);
|
||||
function getTimeChartDateRange(
|
||||
dateRange: [Date, Date],
|
||||
alignDateRangeToGranularity: boolean | undefined,
|
||||
granularity: string,
|
||||
) {
|
||||
return alignDateRangeToGranularity === false
|
||||
? dateRange
|
||||
: getAlignedDateRange(dateRange, granularity);
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
dateRange,
|
||||
dateRangeEndInclusive: false,
|
||||
export function convertToTimeChartConfig(
|
||||
config: ChartConfigWithDateRange,
|
||||
): ChartConfigWithDateRange {
|
||||
const granularity = getTimeChartGranularity(
|
||||
config.granularity,
|
||||
config.dateRange,
|
||||
);
|
||||
|
||||
const dateRange = getTimeChartDateRange(
|
||||
config.dateRange,
|
||||
config.alignDateRangeToGranularity,
|
||||
granularity,
|
||||
limit: { limit: 100000 },
|
||||
};
|
||||
);
|
||||
|
||||
return isBuilderChartConfig(config)
|
||||
? {
|
||||
...config,
|
||||
dateRange,
|
||||
dateRangeEndInclusive: false,
|
||||
granularity,
|
||||
limit: { limit: 100000 },
|
||||
}
|
||||
: {
|
||||
...config,
|
||||
dateRangeEndInclusive: false,
|
||||
dateRange,
|
||||
granularity,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimeChartSettings(
|
||||
chartConfig: BuilderChartConfigWithDateRange,
|
||||
config: Pick<
|
||||
ChartConfigWithDateRange,
|
||||
| 'displayType'
|
||||
| 'dateRange'
|
||||
| 'fillNulls'
|
||||
| 'granularity'
|
||||
| 'alignDateRangeToGranularity'
|
||||
>,
|
||||
) {
|
||||
return useMemo(() => {
|
||||
const convertedConfig = convertToTimeChartConfig(chartConfig);
|
||||
const granularity = getTimeChartGranularity(
|
||||
config.granularity,
|
||||
config.dateRange,
|
||||
);
|
||||
|
||||
const dateRange = getTimeChartDateRange(
|
||||
config.dateRange,
|
||||
config.alignDateRangeToGranularity,
|
||||
granularity,
|
||||
);
|
||||
|
||||
return {
|
||||
displayType: convertedConfig.displayType,
|
||||
dateRange: convertedConfig.dateRange,
|
||||
fillNulls: convertedConfig.fillNulls,
|
||||
granularity: convertedConfig.granularity,
|
||||
displayType: config.displayType,
|
||||
fillNulls: config.fillNulls,
|
||||
dateRange,
|
||||
granularity,
|
||||
};
|
||||
}, [chartConfig]);
|
||||
}, [config]);
|
||||
}
|
||||
|
||||
export function seriesToSearchQuery({
|
||||
|
|
@ -248,23 +294,6 @@ export function TableToggle({
|
|||
export const ChartKeyJoiner = ' · ';
|
||||
export const PreviousPeriodSuffix = ' (previous)';
|
||||
|
||||
export function convertGranularityToSeconds(granularity: SQLInterval): number {
|
||||
const [num, unit] = granularity.split(' ');
|
||||
const numInt = Number.parseInt(num);
|
||||
switch (unit) {
|
||||
case 'second':
|
||||
return numInt;
|
||||
case 'minute':
|
||||
return numInt * 60;
|
||||
case 'hour':
|
||||
return numInt * 60 * 60;
|
||||
case 'day':
|
||||
return numInt * 60 * 60 * 24;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: roundToNearestMinutes is broken in date-fns currently
|
||||
// additionally it doesn't support seconds or > 30min
|
||||
// so we need to write our own :(
|
||||
|
|
@ -621,13 +650,13 @@ function addResponseToFormattedData({
|
|||
}) {
|
||||
const { meta, data } = response;
|
||||
if (meta == null) {
|
||||
throw new Error('No meta data found in response');
|
||||
throw new Error('No metadata found in response');
|
||||
}
|
||||
|
||||
const timestampColumn = inferTimestampColumn(meta);
|
||||
if (timestampColumn == null) {
|
||||
throw new Error(
|
||||
`No timestamp column found with meta: ${JSON.stringify(meta)}`,
|
||||
`No timestamp column found in result column metadata: ${JSON.stringify(meta)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -653,7 +682,10 @@ function addResponseToFormattedData({
|
|||
const currentPeriodKey = [
|
||||
// Simplify the display name if there's only one series and a group by
|
||||
...(isSingleValueColumn && hasGroupColumns ? [] : [valueColumn.name]),
|
||||
...groupColumns.map(g => row[g.name]),
|
||||
...groupColumns.map(g => {
|
||||
const v = row[g.name];
|
||||
return typeof v === 'object' && v !== null ? JSON.stringify(v) : v;
|
||||
}),
|
||||
].join(ChartKeyJoiner);
|
||||
const previousPeriodKey = `${currentPeriodKey}${PreviousPeriodSuffix}`;
|
||||
const keyName = isPreviousPeriod ? previousPeriodKey : currentPeriodKey;
|
||||
|
|
@ -719,7 +751,13 @@ export function formatResponseForTimeChart({
|
|||
|
||||
if (timestampColumn == null) {
|
||||
throw new Error(
|
||||
`No timestamp column found with meta: ${JSON.stringify(meta)}`,
|
||||
`No timestamp column found in result column metadata. Make sure a Date/DateTime column exists in the result set.\n\nResult column metadata: ${JSON.stringify(meta)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (valueColumns.length === 0) {
|
||||
throw new Error(
|
||||
`No value columns found in result column metadata. Make sure a numeric column exists in the result set.\n\nResult column metadata: ${JSON.stringify(meta)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ import { useDashboardRefresh } from './hooks/useDashboardRefresh';
|
|||
import { useBrandDisplayName } from './theme/ThemeProvider';
|
||||
import { parseAsStringEncoded } from './utils/queryParsers';
|
||||
import { buildTableRowSearchUrl, DEFAULT_CHART_CONFIG } from './ChartUtils';
|
||||
import { IS_LOCAL_MODE } from './config';
|
||||
import { useConnections } from './connection';
|
||||
import { useDashboard } from './dashboard';
|
||||
import DashboardFilters from './DashboardFilters';
|
||||
|
|
@ -375,28 +374,30 @@ const Tile = forwardRef(
|
|||
}
|
||||
>
|
||||
{(queriedConfig?.displayType === DisplayType.Line ||
|
||||
queriedConfig?.displayType === DisplayType.StackedBar) &&
|
||||
isBuilderChartConfig(queriedConfig) &&
|
||||
isBuilderSavedChartConfig(chart.config) && (
|
||||
<DBTimeChart
|
||||
key={`${keyPrefix}-${chart.id}`}
|
||||
title={title}
|
||||
toolbarPrefix={toolbar}
|
||||
sourceId={chart.config.source}
|
||||
showDisplaySwitcher={true}
|
||||
config={queriedConfig}
|
||||
onTimeRangeSelect={onTimeRangeSelect}
|
||||
setDisplayType={displayType => {
|
||||
onUpdateChart?.({
|
||||
...chart,
|
||||
config: {
|
||||
...chart.config,
|
||||
displayType,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
queriedConfig?.displayType === DisplayType.StackedBar) && (
|
||||
<DBTimeChart
|
||||
key={`${keyPrefix}-${chart.id}`}
|
||||
title={title}
|
||||
toolbarPrefix={toolbar}
|
||||
sourceId={
|
||||
isBuilderSavedChartConfig(chart.config)
|
||||
? chart.config.source
|
||||
: undefined
|
||||
}
|
||||
showDisplaySwitcher={true}
|
||||
config={queriedConfig}
|
||||
onTimeRangeSelect={onTimeRangeSelect}
|
||||
setDisplayType={displayType => {
|
||||
onUpdateChart?.({
|
||||
...chart,
|
||||
config: {
|
||||
...chart.config,
|
||||
displayType,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{queriedConfig?.displayType === DisplayType.Table && (
|
||||
<Box p="xs" h="100%">
|
||||
<DBTableChart
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
YAxis,
|
||||
} from 'recharts';
|
||||
import { AxisDomain } from 'recharts/types/util/types';
|
||||
import { convertGranularityToSeconds } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import { DisplayType } from '@hyperdx/common-utils/dist/types';
|
||||
import { Popover } from '@mantine/core';
|
||||
|
||||
|
|
@ -28,11 +29,7 @@ import {
|
|||
ChartTooltipContainer,
|
||||
ChartTooltipItem,
|
||||
} from './components/charts/ChartTooltip';
|
||||
import {
|
||||
convertGranularityToSeconds,
|
||||
LineData,
|
||||
toStartOfInterval,
|
||||
} from './ChartUtils';
|
||||
import { LineData, toStartOfInterval } from './ChartUtils';
|
||||
import { FormatTime, useFormatTime } from './useFormatTime';
|
||||
|
||||
import styles from '../styles/HDXLineChart.module.scss';
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ describe('ChartUtils', () => {
|
|||
generateEmptyBuckets: false,
|
||||
}),
|
||||
).toThrow(
|
||||
'No timestamp column found with meta: [{"name":"AVG(toFloat64OrDefault(toString(Duration)))","type":"Float64"}]',
|
||||
'No timestamp column found in result column metadata. Make sure a Date/DateTime column exists in the result set.\n\nResult column metadata: [{"name":"AVG(toFloat64OrDefault(toString(Duration)))","type":"Float64"}]',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -533,6 +533,113 @@ describe('ChartUtils', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('should use only the first timestamp column when multiple are present', () => {
|
||||
const res = {
|
||||
data: [
|
||||
{
|
||||
'count()': 10,
|
||||
first_timestamp: '2025-11-26T11:12:00Z',
|
||||
other_timestamp: '2025-11-26T11:13:00Z',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'count()',
|
||||
type: 'UInt64',
|
||||
},
|
||||
{
|
||||
name: 'first_timestamp',
|
||||
type: 'DateTime',
|
||||
},
|
||||
{
|
||||
name: 'other_timestamp',
|
||||
type: 'DateTime',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const actual = formatResponseForTimeChart({
|
||||
currentPeriodResponse: res,
|
||||
dateRange: [new Date(), new Date()],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: false,
|
||||
});
|
||||
|
||||
expect(actual.timestampColumn).toEqual({
|
||||
name: 'first_timestamp',
|
||||
type: 'DateTime',
|
||||
});
|
||||
expect(actual.graphResults).toEqual([
|
||||
{
|
||||
first_timestamp: 1764155520,
|
||||
'count()': 10,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should treat Map, String, and Array type columns as group columns', () => {
|
||||
const res = {
|
||||
data: [
|
||||
{
|
||||
'count()': 5,
|
||||
string_col: 'foo',
|
||||
map_col: { key: 'val' },
|
||||
array_col: [1, 2, 3],
|
||||
__hdx_time_bucket: '2025-11-26T11:12:00Z',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'count()',
|
||||
type: 'UInt64',
|
||||
},
|
||||
{
|
||||
name: 'string_col',
|
||||
type: 'String',
|
||||
},
|
||||
{
|
||||
name: 'map_col',
|
||||
type: 'Map(String, String)',
|
||||
},
|
||||
{
|
||||
name: 'array_col',
|
||||
type: 'Array(UInt64)',
|
||||
},
|
||||
{
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const actual = formatResponseForTimeChart({
|
||||
currentPeriodResponse: res,
|
||||
dateRange: [new Date(), new Date()],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: false,
|
||||
});
|
||||
|
||||
// All three non-numeric, non-timestamp columns form the group key.
|
||||
// With a single value column, the value column name is omitted from the key.
|
||||
// Map and Array values are serialized as JSON strings.
|
||||
expect(actual.graphResults).toEqual([
|
||||
{
|
||||
__hdx_time_bucket: 1764155520,
|
||||
'foo · {"key":"val"} · [1,2,3]': 5,
|
||||
},
|
||||
]);
|
||||
expect(actual.lineData).toEqual([
|
||||
{
|
||||
color: COLORS[0],
|
||||
dataKey: 'foo · {"key":"val"} · [1,2,3]',
|
||||
currentPeriodKey: 'foo · {"key":"val"} · [1,2,3]',
|
||||
previousPeriodKey: 'foo · {"key":"val"} · [1,2,3] (previous)',
|
||||
displayName: 'foo · {"key":"val"} · [1,2,3]',
|
||||
isDashed: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should plot previous period data when provided, shifted to align with current period', () => {
|
||||
const currentPeriodResponse = {
|
||||
data: [
|
||||
|
|
|
|||
|
|
@ -1,28 +1,7 @@
|
|||
import { useEffect } from 'react';
|
||||
import { atom, useAtom } from 'jotai';
|
||||
import { Control, UseFormSetValue, useWatch } from 'react-hook-form';
|
||||
import { QUERY_PARAMS_BY_DISPLAY_TYPE } from '@hyperdx/common-utils/dist/rawSqlParams';
|
||||
import { DisplayType } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Collapse,
|
||||
Group,
|
||||
List,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCopy,
|
||||
} from '@tabler/icons-react';
|
||||
import { Box, Button, Group, Stack, Text } from '@mantine/core';
|
||||
|
||||
import useResizable from '@/hooks/useResizable';
|
||||
import { useSources } from '@/source';
|
||||
|
|
@ -31,98 +10,11 @@ import { ConnectionSelectControlled } from '../ConnectionSelect';
|
|||
import { SQLEditorControlled } from '../SQLEditor';
|
||||
|
||||
import { SQL_PLACEHOLDERS } from './constants';
|
||||
import { RawSqlChartInstructions } from './RawSqlChartInstructions';
|
||||
import { ChartEditorFormState } from './types';
|
||||
|
||||
import resizeStyles from '@/../styles/ResizablePanel.module.scss';
|
||||
|
||||
function ParamSnippet({
|
||||
value,
|
||||
description,
|
||||
}: {
|
||||
value: string;
|
||||
description: string;
|
||||
}) {
|
||||
const clipboard = useClipboard({ timeout: 1500 });
|
||||
|
||||
return (
|
||||
<Group gap={4} display="inline-flex">
|
||||
<Code fz="xs">{value}</Code>
|
||||
<Tooltip label={clipboard.copied ? 'Copied!' : 'Copy'} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color={clipboard.copied ? 'green' : 'gray'}
|
||||
onClick={() => clipboard.copy(value)}
|
||||
>
|
||||
{clipboard.copied ? <IconCheck size={10} /> : <IconCopy size={10} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Text span size="xs">
|
||||
— {description}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const helpOpenedAtom = atom(true);
|
||||
|
||||
function AvailableParameters({ displayType }: { displayType: DisplayType }) {
|
||||
const [helpOpened, setHelpOpened] = useAtom(helpOpenedAtom);
|
||||
const toggleHelp = () => setHelpOpened(v => !v);
|
||||
const availableParams = QUERY_PARAMS_BY_DISPLAY_TYPE[displayType];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="xs"
|
||||
radius="sm"
|
||||
style={{
|
||||
background: 'var(--color-bg-muted)',
|
||||
}}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Group
|
||||
gap="xs"
|
||||
align="center"
|
||||
style={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={toggleHelp}
|
||||
>
|
||||
{helpOpened ? (
|
||||
<IconChevronDown size={12} />
|
||||
) : (
|
||||
<IconChevronRight size={12} />
|
||||
)}
|
||||
<Text size="xs" mt={1}>
|
||||
Query parameters
|
||||
</Text>
|
||||
</Group>
|
||||
<Collapse in={helpOpened}>
|
||||
<Stack gap={6} pl="xs" pt="md">
|
||||
<Text size="xs">
|
||||
The following parameters can be referenced in this chart's SQL:
|
||||
</Text>
|
||||
<List size="xs" withPadding spacing={3}>
|
||||
{availableParams.map(({ name, type, description }) => (
|
||||
<List.Item key={name}>
|
||||
<ParamSnippet
|
||||
value={`{${name}:${type}}`}
|
||||
description={description}
|
||||
/>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
<Text size="xs">Example:</Text>
|
||||
<Code fz="xs" block>
|
||||
{
|
||||
'WHERE Timestamp >= fromUnixTimestamp64Milli ({startDateMilliseconds:Int64})\n AND Timestamp <= fromUnixTimestamp64Milli ({endDateMilliseconds:Int64})'
|
||||
}
|
||||
</Code>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RawSqlChartEditor({
|
||||
control,
|
||||
setValue,
|
||||
|
|
@ -166,7 +58,7 @@ export default function RawSqlChartEditor({
|
|||
size="xs"
|
||||
/>
|
||||
</Group>
|
||||
<AvailableParameters displayType={displayType ?? DisplayType.Table} />
|
||||
<RawSqlChartInstructions displayType={displayType ?? DisplayType.Table} />
|
||||
<Box style={{ position: 'relative' }}>
|
||||
<SQLEditorControlled
|
||||
control={control}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
import { atom, useAtom } from 'jotai';
|
||||
import {
|
||||
QUERY_PARAM_EXAMPLES,
|
||||
QUERY_PARAMS_BY_DISPLAY_TYPE,
|
||||
} from '@hyperdx/common-utils/dist/rawSqlParams';
|
||||
import { DisplayType } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Code,
|
||||
Collapse,
|
||||
Group,
|
||||
List,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCopy,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const helpOpenedAtom = atom(true);
|
||||
|
||||
function ParamSnippet({
|
||||
value,
|
||||
description,
|
||||
}: {
|
||||
value: string;
|
||||
description: string;
|
||||
}) {
|
||||
const clipboard = useClipboard({ timeout: 1500 });
|
||||
|
||||
return (
|
||||
<Group gap={4} display="inline-flex">
|
||||
<Code fz="xs">{value}</Code>
|
||||
<Tooltip label={clipboard.copied ? 'Copied!' : 'Copy'} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color={clipboard.copied ? 'green' : 'gray'}
|
||||
onClick={() => clipboard.copy(value)}
|
||||
>
|
||||
{clipboard.copied ? <IconCheck size={10} /> : <IconCopy size={10} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Text span size="xs">
|
||||
— {description}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawSqlChartInstructions({
|
||||
displayType,
|
||||
}: {
|
||||
displayType: DisplayType;
|
||||
}) {
|
||||
const [helpOpened, setHelpOpened] = useAtom(helpOpenedAtom);
|
||||
const toggleHelp = () => setHelpOpened(v => !v);
|
||||
const availableParams = QUERY_PARAMS_BY_DISPLAY_TYPE[displayType];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="xs"
|
||||
radius="sm"
|
||||
style={{
|
||||
background: 'var(--color-bg-muted)',
|
||||
}}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Group
|
||||
gap="xs"
|
||||
align="center"
|
||||
style={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={toggleHelp}
|
||||
>
|
||||
{helpOpened ? (
|
||||
<IconChevronDown size={12} />
|
||||
) : (
|
||||
<IconChevronRight size={12} />
|
||||
)}
|
||||
<Text size="xs" mt={1}>
|
||||
SQL Chart Instructions
|
||||
</Text>
|
||||
</Group>
|
||||
<Collapse in={helpOpened}>
|
||||
<Stack gap={6} pl="xs" pt="md">
|
||||
{(displayType === DisplayType.Line ||
|
||||
displayType === DisplayType.StackedBar) && (
|
||||
<>
|
||||
<Text size="xs" fw="bold">
|
||||
Result columns are plotted as follows:
|
||||
</Text>
|
||||
<List size="xs" withPadding spacing={3} mb="xs">
|
||||
<List.Item>
|
||||
<Text span size="xs" fw={600}>
|
||||
Timestamp
|
||||
</Text>
|
||||
<Text span size="xs">
|
||||
{' '}
|
||||
— The first <Code fz="xs">Date</Code> or{' '}
|
||||
<Code fz="xs">DateTime</Code> column.
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text span size="xs" fw={600}>
|
||||
Series Value
|
||||
</Text>
|
||||
<Text span size="xs">
|
||||
{' '}
|
||||
— Each numeric column will be plotted as a separate
|
||||
series. These columns are generally aggregate function
|
||||
values.
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text span size="xs" fw={600}>
|
||||
Group Names
|
||||
</Text>
|
||||
<Text span size="xs">
|
||||
{' '}
|
||||
(optional) — Any string, map, or array type result column
|
||||
will be treated as a group column. Result rows with
|
||||
different group column values will be plotted as separate
|
||||
series.
|
||||
</Text>
|
||||
</List.Item>
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text size="xs" fw="bold">
|
||||
The following parameters can be referenced in this chart's SQL:
|
||||
</Text>
|
||||
<List size="xs" withPadding spacing={3} mb="xs">
|
||||
{availableParams.map(({ name, type, description }) => (
|
||||
<List.Item key={name}>
|
||||
<ParamSnippet
|
||||
value={`{${name}:${type}}`}
|
||||
description={description}
|
||||
/>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Text size="xs" fw="bold">
|
||||
Example:
|
||||
</Text>
|
||||
<Code fz="xs" block>
|
||||
{QUERY_PARAM_EXAMPLES[displayType]}
|
||||
</Code>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
|
@ -84,7 +84,7 @@ describe('convertFormStateToSavedChartConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns undefined for sql config without Table displayType', () => {
|
||||
it('returns a raw SQL config for Line displayType', () => {
|
||||
const form: ChartEditorFormState = {
|
||||
configType: 'sql',
|
||||
displayType: DisplayType.Line,
|
||||
|
|
@ -92,6 +92,23 @@ describe('convertFormStateToSavedChartConfig', () => {
|
|||
connection: 'conn-1',
|
||||
series: [],
|
||||
};
|
||||
const result = convertFormStateToSavedChartConfig(form, undefined);
|
||||
expect(result).toEqual({
|
||||
configType: 'sql',
|
||||
displayType: DisplayType.Line,
|
||||
sqlTemplate: 'SELECT 1',
|
||||
connection: 'conn-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for sql config with an unsupported displayType', () => {
|
||||
const form: ChartEditorFormState = {
|
||||
configType: 'sql',
|
||||
displayType: DisplayType.Pie,
|
||||
sqlTemplate: 'SELECT 1',
|
||||
connection: 'conn-1',
|
||||
series: [],
|
||||
};
|
||||
expect(convertFormStateToSavedChartConfig(form, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
import { DisplayType } from '@hyperdx/common-utils/dist/types';
|
||||
|
||||
const TIMESERIES_PLACEHOLDER_SQL = `SELECT
|
||||
toStartOfInterval(TimestampTime, INTERVAL {intervalSeconds:Int64} SECOND) AS ts,
|
||||
SeverityText,
|
||||
count() AS count
|
||||
FROM
|
||||
default.otel_logs
|
||||
WHERE TimestampTime >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})
|
||||
AND TimestampTime < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})
|
||||
GROUP BY ts, SeverityText
|
||||
ORDER BY ts ASC;`;
|
||||
|
||||
export const SQL_PLACEHOLDERS: Record<DisplayType, string> = {
|
||||
[DisplayType.Line]: '',
|
||||
[DisplayType.StackedBar]: '',
|
||||
[DisplayType.Line]: TIMESERIES_PLACEHOLDER_SQL,
|
||||
[DisplayType.StackedBar]: TIMESERIES_PLACEHOLDER_SQL,
|
||||
[DisplayType.Table]: `SELECT
|
||||
count()
|
||||
FROM
|
||||
|
|
|
|||
|
|
@ -42,11 +42,21 @@ function normalizeChartConfig<
|
|||
};
|
||||
}
|
||||
|
||||
export const isRawSqlDisplayType = (
|
||||
displayType: DisplayType | undefined,
|
||||
): displayType is
|
||||
| DisplayType.Table
|
||||
| DisplayType.Line
|
||||
| DisplayType.StackedBar =>
|
||||
displayType === DisplayType.Table ||
|
||||
displayType === DisplayType.Line ||
|
||||
displayType === DisplayType.StackedBar;
|
||||
|
||||
export function convertFormStateToSavedChartConfig(
|
||||
form: ChartEditorFormState,
|
||||
source: TSource | undefined,
|
||||
): SavedChartConfig | undefined {
|
||||
if (form.configType === 'sql' && form.displayType === DisplayType.Table) {
|
||||
if (form.configType === 'sql' && isRawSqlDisplayType(form.displayType)) {
|
||||
const rawSqlConfig: RawSqlSavedChartConfig = {
|
||||
configType: 'sql',
|
||||
...pick(form, [
|
||||
|
|
@ -88,7 +98,7 @@ export function convertFormStateToChartConfig(
|
|||
dateRange: ChartConfigWithDateRange['dateRange'],
|
||||
source: TSource | undefined,
|
||||
): ChartConfigWithDateRange | undefined {
|
||||
if (form.configType === 'sql' && form.displayType === DisplayType.Table) {
|
||||
if (form.configType === 'sql' && isRawSqlDisplayType(form.displayType)) {
|
||||
const rawSqlConfig: RawSqlChartConfig = {
|
||||
configType: 'sql',
|
||||
...pick(form, [
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { format } from '@hyperdx/common-utils/dist/sqlFormatter';
|
|||
import { ChartConfigWithOptDateRange } from '@hyperdx/common-utils/dist/types';
|
||||
import { Button, Paper } from '@mantine/core';
|
||||
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import CodeMirror, { EditorView } from '@uiw/react-codemirror';
|
||||
|
||||
import { useRenderedSqlChartConfig } from '@/hooks/useChartConfig';
|
||||
|
||||
|
|
@ -55,11 +55,13 @@ export function SQLPreview({
|
|||
formatData = true,
|
||||
enableCopy = false,
|
||||
copyButtonSize = 'md',
|
||||
enableLineWrapping = false,
|
||||
}: {
|
||||
data?: string;
|
||||
formatData?: boolean;
|
||||
enableCopy?: boolean;
|
||||
copyButtonSize?: 'xs' | 'md';
|
||||
enableLineWrapping?: boolean;
|
||||
}) {
|
||||
const displayed = formatData ? tryFormat(data) : data;
|
||||
|
||||
|
|
@ -75,7 +77,10 @@ export function SQLPreview({
|
|||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
}}
|
||||
extensions={[sql()]}
|
||||
extensions={[
|
||||
sql(),
|
||||
...(enableLineWrapping ? [EditorView.lineWrapping] : []),
|
||||
]}
|
||||
editable={false}
|
||||
/>
|
||||
{enableCopy && <CopyButton text={displayed} size={copyButtonSize} />}
|
||||
|
|
@ -86,14 +91,22 @@ export function SQLPreview({
|
|||
// TODO: Support clicking in to view matched events
|
||||
export default function ChartSQLPreview({
|
||||
config,
|
||||
enableCopy,
|
||||
}: {
|
||||
config: ChartConfigWithOptDateRange;
|
||||
enableCopy?: boolean;
|
||||
}) {
|
||||
const { data } = useRenderedSqlChartConfig(config);
|
||||
|
||||
return (
|
||||
<Paper flex="auto" shadow="none" radius="sm" style={{ overflow: 'hidden' }}>
|
||||
<SQLPreview data={data} formatData={false} />
|
||||
<Paper
|
||||
flex="auto"
|
||||
shadow="none"
|
||||
radius="sm"
|
||||
style={{ overflow: 'hidden' }}
|
||||
p="xs"
|
||||
>
|
||||
<SQLPreview data={data} formatData={false} enableCopy={enableCopy} />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ import {
|
|||
convertFormStateToSavedChartConfig,
|
||||
convertSavedChartConfigToFormState,
|
||||
getSeriesFieldPath,
|
||||
isRawSqlDisplayType,
|
||||
validateMetricNames,
|
||||
} from './ChartEditor/utils';
|
||||
import { ErrorBoundary } from './Error/ErrorBoundary';
|
||||
|
|
@ -601,7 +602,7 @@ export default function EditTimeChartForm({
|
|||
: undefined;
|
||||
|
||||
const isRawSqlInput =
|
||||
configType === 'sql' && displayType === DisplayType.Table;
|
||||
configType === 'sql' && isRawSqlDisplayType(displayType);
|
||||
|
||||
const { data: tableSource } = useSource({ id: sourceId });
|
||||
const databaseName = tableSource?.from.databaseName;
|
||||
|
|
@ -694,7 +695,7 @@ export default function EditTimeChartForm({
|
|||
);
|
||||
|
||||
const dbTimeChartConfig = useMemo(() => {
|
||||
if (!queriedConfig || !isBuilderChartConfig(queriedConfig)) {
|
||||
if (!queriedConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -715,7 +716,7 @@ export default function EditTimeChartForm({
|
|||
const onSubmit = useCallback(() => {
|
||||
handleSubmit(form => {
|
||||
const isRawSqlChart =
|
||||
form.configType === 'sql' && form.displayType === DisplayType.Table;
|
||||
form.configType === 'sql' && isRawSqlDisplayType(form.displayType);
|
||||
|
||||
if (
|
||||
!isRawSqlChart &&
|
||||
|
|
@ -799,7 +800,7 @@ export default function EditTimeChartForm({
|
|||
const handleSave = useCallback(
|
||||
(form: ChartEditorFormState) => {
|
||||
const isRawSqlChart =
|
||||
form.configType === 'sql' && form.displayType === DisplayType.Table;
|
||||
form.configType === 'sql' && isRawSqlDisplayType(form.displayType);
|
||||
|
||||
// Validate metric sources have metric names selected
|
||||
if (
|
||||
|
|
@ -898,6 +899,7 @@ export default function EditTimeChartForm({
|
|||
|
||||
// Emulate the date range picker auto-searching similar to dashboards
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setQueriedConfig((config: ChartConfigWithDateRange | undefined) => {
|
||||
if (config == null) {
|
||||
return config;
|
||||
|
|
@ -1097,7 +1099,7 @@ export default function EditTimeChartForm({
|
|||
placeholder="My Chart Name"
|
||||
data-testid="chart-name-input"
|
||||
/>
|
||||
{displayType === DisplayType.Table && (
|
||||
{isRawSqlDisplayType(displayType) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="configType"
|
||||
|
|
@ -1359,7 +1361,7 @@ export default function EditTimeChartForm({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{alert && (
|
||||
{alert && !isRawSqlInput && (
|
||||
<Paper my="sm">
|
||||
<Stack gap="xs" data-testid="alert-details">
|
||||
<Paper px="md" py="sm" radius="xs">
|
||||
|
|
@ -1573,14 +1575,20 @@ 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 &&
|
||||
isBuilderChartConfig(queriedConfig) &&
|
||||
activeTab === 'pie' && (
|
||||
<div
|
||||
className="flex-grow-1 d-flex flex-column"
|
||||
style={{ height: 400 }}
|
||||
>
|
||||
<DBPieChart
|
||||
config={queriedConfig}
|
||||
showMVOptimizationIndicator={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{queryReady &&
|
||||
queriedConfig != null &&
|
||||
isBuilderChartConfig(queriedConfig) &&
|
||||
|
|
@ -1679,7 +1687,10 @@ export default function EditTimeChartForm({
|
|||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{queryReady && chartConfigForExplanations != null && (
|
||||
<ChartSQLPreview config={chartConfigForExplanations} />
|
||||
<ChartSQLPreview
|
||||
config={chartConfigForExplanations}
|
||||
enableCopy
|
||||
/>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
|||
import Link from 'next/link';
|
||||
import { add, differenceInSeconds } from 'date-fns';
|
||||
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { getAlignedDateRange } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
convertGranularityToSeconds,
|
||||
getAlignedDateRange,
|
||||
} from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
isBuilderChartConfig,
|
||||
isRawSqlChartConfig,
|
||||
} from '@hyperdx/common-utils/dist/guards';
|
||||
import {
|
||||
BuilderChartConfigWithDateRange,
|
||||
ChartConfigWithDateRange,
|
||||
|
|
@ -33,7 +40,6 @@ import {
|
|||
AGG_FNS,
|
||||
buildEventsSearchUrl,
|
||||
ChartKeyJoiner,
|
||||
convertGranularityToSeconds,
|
||||
convertToTimeChartConfig,
|
||||
formatResponseForTimeChart,
|
||||
getPreviousDateRange,
|
||||
|
|
@ -199,8 +205,59 @@ function ActiveTimeTooltip({
|
|||
);
|
||||
}
|
||||
|
||||
function ErrorView({ error }: { error: Error | ClickHouseQueryError }) {
|
||||
const [isErrorExpanded, errorExpansion] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<div className="h-100 w-100 d-flex g-1 flex-column align-items-center justify-content-center text-muted overflow-auto">
|
||||
<Text ta="center" size="sm" mt="sm">
|
||||
Error loading chart, please check your query or try again later.
|
||||
</Text>
|
||||
<Button
|
||||
className="mx-auto"
|
||||
variant="danger"
|
||||
onClick={() => errorExpansion.open()}
|
||||
>
|
||||
<Group gap="xxs">
|
||||
<IconArrowsDiagonal size={16} />
|
||||
See Error Details
|
||||
</Group>
|
||||
</Button>
|
||||
<Modal
|
||||
opened={isErrorExpanded}
|
||||
onClose={() => errorExpansion.close()}
|
||||
title="Error Details"
|
||||
size="lg"
|
||||
>
|
||||
<Stack align="start">
|
||||
<Text size="sm" mt={10}>
|
||||
Error Message:
|
||||
</Text>
|
||||
<Code
|
||||
flex={1}
|
||||
block
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{error.message}
|
||||
</Code>
|
||||
{error instanceof ClickHouseQueryError && (
|
||||
<>
|
||||
<Text size="sm" ta="center">
|
||||
Sent Query:
|
||||
</Text>
|
||||
<SQLPreview data={error?.query} enableLineWrapping />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DBTimeChartComponentProps = {
|
||||
config: BuilderChartConfigWithDateRange;
|
||||
config: ChartConfigWithDateRange;
|
||||
disableQueryChunking?: boolean;
|
||||
disableDrillDown?: boolean;
|
||||
enableParallelQueries?: boolean;
|
||||
|
|
@ -244,7 +301,6 @@ function DBTimeChartComponent({
|
|||
showMVOptimizationIndicator = true,
|
||||
showDateRangeIndicator = true,
|
||||
}: DBTimeChartComponentProps) {
|
||||
const [isErrorExpanded, errorExpansion] = useDisclosure(false);
|
||||
const [selectedSeriesSet, setSelectedSeriesSet] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
|
@ -292,8 +348,12 @@ function DBTimeChartComponent({
|
|||
[config],
|
||||
);
|
||||
|
||||
// Determine whether the config can be optimized with an MV, to determine whether
|
||||
// to show the MV optimization indicator and date range indicator in the toolbar
|
||||
const builderQueriedConfig: BuilderChartConfigWithDateRange | undefined =
|
||||
isBuilderChartConfig(queriedConfig) ? queriedConfig : undefined;
|
||||
const { data: mvOptimizationData } =
|
||||
useMVOptimizationExplanation(queriedConfig);
|
||||
useMVOptimizationExplanation(builderQueriedConfig);
|
||||
|
||||
const { data: me, isLoading: isLoadingMe } = api.useMe();
|
||||
const { data, isLoading, isError, error, isPlaceholderData, isSuccess } =
|
||||
|
|
@ -321,14 +381,14 @@ function DBTimeChartComponent({
|
|||
? getPreviousDateRange(originalDateRange)
|
||||
: getAlignedDateRange(
|
||||
getPreviousDateRange(originalDateRange),
|
||||
queriedConfig.granularity,
|
||||
granularity,
|
||||
);
|
||||
|
||||
return {
|
||||
...queriedConfig,
|
||||
dateRange: previousPeriodDateRange,
|
||||
};
|
||||
}, [queriedConfig, originalDateRange]);
|
||||
}, [queriedConfig, originalDateRange, granularity]);
|
||||
|
||||
const previousPeriodOffsetSeconds = useMemo(() => {
|
||||
return config.compareToPreviousPeriod
|
||||
|
|
@ -351,21 +411,19 @@ function DBTimeChartComponent({
|
|||
enableQueryChunking: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isError && isErrorExpanded) {
|
||||
errorExpansion.close();
|
||||
}
|
||||
}, [isError, isErrorExpanded, errorExpansion]);
|
||||
|
||||
const isLoadingOrPlaceholder =
|
||||
isLoading ||
|
||||
isPreviousPeriodLoading ||
|
||||
!data?.isComplete ||
|
||||
(config.compareToPreviousPeriod && !previousPeriodData?.isComplete) ||
|
||||
isPlaceholderData;
|
||||
const { data: source } = useSource({ id: sourceId || config.source });
|
||||
|
||||
const { data: source } = useSource({
|
||||
id: sourceId || (isBuilderChartConfig(config) ? config.source : undefined),
|
||||
});
|
||||
|
||||
const {
|
||||
error: resultFormattingError,
|
||||
graphResults,
|
||||
timestampColumn,
|
||||
groupColumns,
|
||||
|
|
@ -374,6 +432,7 @@ function DBTimeChartComponent({
|
|||
lineData,
|
||||
} = useMemo(() => {
|
||||
const defaultResponse = {
|
||||
error: null,
|
||||
graphResults: [],
|
||||
timestampColumn: undefined,
|
||||
lineData: [],
|
||||
|
|
@ -387,7 +446,7 @@ function DBTimeChartComponent({
|
|||
}
|
||||
|
||||
try {
|
||||
return formatResponseForTimeChart({
|
||||
const formatResult = formatResponseForTimeChart({
|
||||
currentPeriodResponse: data,
|
||||
previousPeriodResponse: config.compareToPreviousPeriod
|
||||
? previousPeriodData
|
||||
|
|
@ -399,9 +458,16 @@ function DBTimeChartComponent({
|
|||
hiddenSeries,
|
||||
previousPeriodOffsetSeconds,
|
||||
});
|
||||
} catch (e) {
|
||||
return {
|
||||
...defaultResponse,
|
||||
...formatResult,
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
return defaultResponse;
|
||||
return {
|
||||
...defaultResponse,
|
||||
error: e,
|
||||
};
|
||||
}
|
||||
}, [
|
||||
data,
|
||||
|
|
@ -467,7 +533,12 @@ function DBTimeChartComponent({
|
|||
|
||||
const buildSearchUrl = useCallback(
|
||||
(seriesKey?: string, seriesValue?: number) => {
|
||||
if (clickedActiveLabelDate == null || source == null) {
|
||||
// Raw SQL charts are not supported for drill-down as we don't know the source which is being used.
|
||||
if (
|
||||
clickedActiveLabelDate == null ||
|
||||
source == null ||
|
||||
isRawSqlChartConfig(config)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -593,11 +664,11 @@ function DBTimeChartComponent({
|
|||
allToolbarItems.push(...toolbarPrefix);
|
||||
}
|
||||
|
||||
if (source && showMVOptimizationIndicator) {
|
||||
if (source && showMVOptimizationIndicator && builderQueriedConfig) {
|
||||
allToolbarItems.push(
|
||||
<MVOptimizationIndicator
|
||||
key="db-time-chart-mv-indicator"
|
||||
config={queriedConfig}
|
||||
config={builderQueriedConfig}
|
||||
source={source}
|
||||
variant="icon"
|
||||
/>,
|
||||
|
|
@ -658,6 +729,7 @@ function DBTimeChartComponent({
|
|||
|
||||
return allToolbarItems;
|
||||
}, [
|
||||
builderQueriedConfig,
|
||||
config,
|
||||
displayType,
|
||||
handleSetDisplayType,
|
||||
|
|
@ -678,48 +750,15 @@ function DBTimeChartComponent({
|
|||
Loading Chart Data...
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="h-100 w-100 d-flex g-1 flex-column align-items-center justify-content-center text-muted overflow-auto">
|
||||
<Text ta="center" size="sm" mt="sm">
|
||||
Error loading chart, please check your query or try again later.
|
||||
</Text>
|
||||
<Button
|
||||
className="mx-auto"
|
||||
variant="danger"
|
||||
onClick={() => errorExpansion.open()}
|
||||
>
|
||||
<Group gap="xxs">
|
||||
<IconArrowsDiagonal size={16} />
|
||||
See Error Details
|
||||
</Group>
|
||||
</Button>
|
||||
<Modal
|
||||
opened={isErrorExpanded}
|
||||
onClose={() => errorExpansion.close()}
|
||||
title="Error Details"
|
||||
>
|
||||
<Group align="start">
|
||||
<Text 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} />
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Modal>
|
||||
</div>
|
||||
<ErrorView error={error} />
|
||||
) : resultFormattingError ? (
|
||||
<ErrorView
|
||||
error={
|
||||
resultFormattingError instanceof Error
|
||||
? resultFormattingError
|
||||
: new Error(String(resultFormattingError))
|
||||
}
|
||||
/>
|
||||
) : graphResults.length === 0 ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
No data found within time range.
|
||||
|
|
|
|||
|
|
@ -265,6 +265,111 @@ describe('DBTimeChart', () => {
|
|||
expect(dateRangeIndicatorCall.mvGranularity).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('raw SQL line chart', () => {
|
||||
const rawSqlConfig = {
|
||||
configType: 'sql' as const,
|
||||
sqlTemplate:
|
||||
'SELECT toStartOfInterval(ts, INTERVAL {intervalSeconds:Int64} SECOND) AS ts, count() AS count FROM logs GROUP BY ts ORDER BY ts ASC',
|
||||
connection: 'test-connection',
|
||||
displayType: 'line' as any,
|
||||
dateRange: [new Date('2024-01-01'), new Date('2024-01-02')] as [
|
||||
Date,
|
||||
Date,
|
||||
],
|
||||
};
|
||||
|
||||
it('passes the raw SQL config directly to useQueriedChartConfig without converting it', () => {
|
||||
renderWithMantine(<DBTimeChart config={rawSqlConfig} />);
|
||||
|
||||
const firstCallConfig = mockUseQueriedChartConfig.mock.calls[0][0];
|
||||
// The config should be passed as-is, not wrapped by convertToTimeChartConfig
|
||||
// (which would add `limit`, `dateRangeEndInclusive`, etc.)
|
||||
expect(firstCallConfig.configType).toBe('sql');
|
||||
expect(firstCallConfig.sqlTemplate).toBe(rawSqlConfig.sqlTemplate);
|
||||
expect(firstCallConfig).not.toHaveProperty('limit');
|
||||
});
|
||||
|
||||
it('does not pass the raw SQL config to useMVOptimizationExplanation', () => {
|
||||
renderWithMantine(<DBTimeChart config={rawSqlConfig} />);
|
||||
|
||||
// useMVOptimizationExplanation should be called with undefined for raw SQL configs
|
||||
expect(
|
||||
jest.mocked(useMVOptimizationExplanation).mock.calls[0][0],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders without crashing when query returns timestamp and value columns', () => {
|
||||
mockUseQueriedChartConfig.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{ ts: 1704067200, count: 42 },
|
||||
{ ts: 1704067260, count: 17 },
|
||||
],
|
||||
meta: [
|
||||
{ name: 'ts', type: 'DateTime' },
|
||||
{ name: 'count', type: 'UInt64' },
|
||||
],
|
||||
rows: 2,
|
||||
isComplete: true,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
isPlaceholderData: false,
|
||||
});
|
||||
|
||||
// Should render without throwing
|
||||
expect(() =>
|
||||
renderWithMantine(<DBTimeChart config={rawSqlConfig} />),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('renders without crashing when query returns no data', () => {
|
||||
mockUseQueriedChartConfig.mockReturnValue({
|
||||
data: {
|
||||
data: [],
|
||||
meta: [],
|
||||
rows: 0,
|
||||
isComplete: true,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
isPlaceholderData: false,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
renderWithMantine(<DBTimeChart config={rawSqlConfig} />),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('renders without crashing when query returns multiple value columns', () => {
|
||||
mockUseQueriedChartConfig.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{ ts: 1704067200, errors: 5, warnings: 12 },
|
||||
{ ts: 1704067260, errors: 3, warnings: 8 },
|
||||
],
|
||||
meta: [
|
||||
{ name: 'ts', type: 'DateTime' },
|
||||
{ name: 'errors', type: 'UInt64' },
|
||||
{ name: 'warnings', type: 'UInt64' },
|
||||
],
|
||||
rows: 2,
|
||||
isComplete: true,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
isPlaceholderData: false,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
renderWithMantine(<DBTimeChart config={rawSqlConfig} />),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render DateRangeIndicator when MV optimization has no optimized date range and showDateRangeIndicator is false', () => {
|
||||
// Mock useMVOptimizationExplanation to return data without an optimized config
|
||||
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { convertDateRangeToGranularityString } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
convertDateRangeToGranularityString,
|
||||
convertGranularityToSeconds,
|
||||
} from '@hyperdx/common-utils/dist/core/utils';
|
||||
import { useDocumentVisibility } from '@mantine/hooks';
|
||||
|
||||
import { convertGranularityToSeconds } from '@/ChartUtils';
|
||||
|
||||
export const useDashboardRefresh = ({
|
||||
searchedTimeRange,
|
||||
onTimeRangeSelect,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,76 @@ describe('renderRawSqlChartConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('DisplayType.Line', () => {
|
||||
it('returns undefined params when no dateRange is provided', () => {
|
||||
const result = renderRawSqlChartConfig({
|
||||
configType: 'sql',
|
||||
sqlTemplate: 'SELECT ts, count() FROM logs GROUP BY ts',
|
||||
connection: 'conn-1',
|
||||
displayType: DisplayType.Line,
|
||||
});
|
||||
expect(result.params).toEqual({
|
||||
startDateMilliseconds: undefined,
|
||||
endDateMilliseconds: undefined,
|
||||
intervalSeconds: 0,
|
||||
intervalMilliseconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('injects all four params when dateRange is provided', () => {
|
||||
const start = new Date('2024-01-01T00:00:00.000Z');
|
||||
const end = new Date('2024-01-02T00:00:00.000Z');
|
||||
const result = renderRawSqlChartConfig({
|
||||
configType: 'sql',
|
||||
sqlTemplate:
|
||||
'SELECT toStartOfInterval(ts, INTERVAL {intervalSeconds:Int64} SECOND) AS ts, count() FROM logs WHERE ts >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND ts <= fromUnixTimestamp64Milli({endDateMilliseconds:Int64}) GROUP BY ts ORDER BY ts ASC',
|
||||
connection: 'conn-1',
|
||||
displayType: DisplayType.Line,
|
||||
dateRange: [start, end],
|
||||
});
|
||||
expect(result.params.startDateMilliseconds).toBe(start.getTime());
|
||||
expect(result.params.endDateMilliseconds).toBe(end.getTime());
|
||||
expect(typeof result.params.intervalSeconds).toBe('number');
|
||||
expect(result.params.intervalSeconds).toBeGreaterThan(0);
|
||||
expect(result.params.intervalMilliseconds).toBe(
|
||||
result.params.intervalSeconds * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the granularity from the config when available', () => {
|
||||
// 1-hour range: auto-granularity should be 1 minute (60s) for 60 max buckets
|
||||
const start = new Date('2024-01-01T00:00:00.000Z');
|
||||
const end = new Date('2024-01-01T01:00:00.000Z');
|
||||
const result = renderRawSqlChartConfig({
|
||||
configType: 'sql',
|
||||
sqlTemplate: 'SELECT ts, count() FROM logs GROUP BY ts',
|
||||
connection: 'conn-1',
|
||||
displayType: DisplayType.Line,
|
||||
dateRange: [start, end],
|
||||
granularity: '5 minute',
|
||||
});
|
||||
expect(result.params.intervalSeconds).toBe(300); // 5 minutes
|
||||
expect(result.params.intervalMilliseconds).toBe(300000);
|
||||
});
|
||||
|
||||
it('computes intervalSeconds based on the date range duration when granularity is auto', () => {
|
||||
// 1-hour range: auto-granularity should be 1 minute (60s) for 60 max buckets
|
||||
const start = new Date('2024-01-01T00:00:00.000Z');
|
||||
const end = new Date('2024-01-01T01:00:00.000Z');
|
||||
const result = renderRawSqlChartConfig({
|
||||
configType: 'sql',
|
||||
sqlTemplate: 'SELECT ts, count() FROM logs GROUP BY ts',
|
||||
connection: 'conn-1',
|
||||
granularity: 'auto',
|
||||
displayType: DisplayType.Line,
|
||||
dateRange: [start, end],
|
||||
});
|
||||
// 1-hour range / 60 buckets = 60s per bucket → "1 minute" interval → 60 seconds
|
||||
expect(result.params.intervalSeconds).toBe(60);
|
||||
expect(result.params.intervalMilliseconds).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to Table display type when displayType is not specified', () => {
|
||||
const start = new Date('2024-06-15T12:00:00.000Z');
|
||||
const end = new Date('2024-06-15T13:00:00.000Z');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { ChSql } from './clickhouse';
|
||||
import {
|
||||
convertDateRangeToGranularityString,
|
||||
convertGranularityToSeconds,
|
||||
} from './core/utils';
|
||||
import { DateRange, DisplayType, RawSqlChartConfig } from './types';
|
||||
|
||||
type QueryParamDefinition = {
|
||||
|
|
@ -8,6 +12,17 @@ type QueryParamDefinition = {
|
|||
get: (config: RawSqlChartConfig & Partial<DateRange>) => any;
|
||||
};
|
||||
|
||||
const getIntervalSeconds = (config: RawSqlChartConfig & Partial<DateRange>) => {
|
||||
const granularity = config.granularity ?? 'auto';
|
||||
|
||||
const effectiveGranularity =
|
||||
granularity === 'auto' && config.dateRange
|
||||
? convertDateRangeToGranularityString(config.dateRange)
|
||||
: granularity;
|
||||
|
||||
return convertGranularityToSeconds(effectiveGranularity);
|
||||
};
|
||||
|
||||
export const QUERY_PARAMS: Record<string, QueryParamDefinition> = {
|
||||
startDateMilliseconds: {
|
||||
name: 'startDateMilliseconds',
|
||||
|
|
@ -24,14 +39,37 @@ export const QUERY_PARAMS: Record<string, QueryParamDefinition> = {
|
|||
get: (config: RawSqlChartConfig & Partial<DateRange>) =>
|
||||
config.dateRange ? config.dateRange[1].getTime() : undefined,
|
||||
},
|
||||
intervalSeconds: {
|
||||
name: 'intervalSeconds',
|
||||
type: 'Int64',
|
||||
description: 'time bucket size in seconds',
|
||||
get: getIntervalSeconds,
|
||||
},
|
||||
intervalMilliseconds: {
|
||||
name: 'intervalMilliseconds',
|
||||
type: 'Int64',
|
||||
description: 'time bucket size in milliseconds',
|
||||
get: (config: RawSqlChartConfig & Partial<DateRange>) =>
|
||||
getIntervalSeconds(config) * 1000,
|
||||
},
|
||||
};
|
||||
|
||||
export const QUERY_PARAMS_BY_DISPLAY_TYPE: Record<
|
||||
DisplayType,
|
||||
QueryParamDefinition[]
|
||||
> = {
|
||||
[DisplayType.Line]: [],
|
||||
[DisplayType.StackedBar]: [],
|
||||
[DisplayType.Line]: [
|
||||
QUERY_PARAMS.startDateMilliseconds,
|
||||
QUERY_PARAMS.endDateMilliseconds,
|
||||
QUERY_PARAMS.intervalSeconds,
|
||||
QUERY_PARAMS.intervalMilliseconds,
|
||||
],
|
||||
[DisplayType.StackedBar]: [
|
||||
QUERY_PARAMS.startDateMilliseconds,
|
||||
QUERY_PARAMS.endDateMilliseconds,
|
||||
QUERY_PARAMS.intervalSeconds,
|
||||
QUERY_PARAMS.intervalMilliseconds,
|
||||
],
|
||||
[DisplayType.Table]: [
|
||||
QUERY_PARAMS.startDateMilliseconds,
|
||||
QUERY_PARAMS.endDateMilliseconds,
|
||||
|
|
@ -43,6 +81,27 @@ export const QUERY_PARAMS_BY_DISPLAY_TYPE: Record<
|
|||
[DisplayType.Markdown]: [],
|
||||
};
|
||||
|
||||
const TIME_CHART_EXAMPLE_SQL = `SELECT
|
||||
toStartOfInterval(TimestampTime, INTERVAL {intervalSeconds:Int64} second) AS ts, -- (Timestamp column)
|
||||
ServiceName, -- (Group name column)
|
||||
count() -- (Series value column)
|
||||
FROM otel_logs
|
||||
WHERE TimestampTime >= fromUnixTimestamp64Milli ({startDateMilliseconds:Int64})
|
||||
AND TimestampTime < fromUnixTimestamp64Milli ({endDateMilliseconds:Int64})
|
||||
GROUP BY ServiceName, ts`;
|
||||
|
||||
export const QUERY_PARAM_EXAMPLES: Record<DisplayType, string> = {
|
||||
[DisplayType.Line]: TIME_CHART_EXAMPLE_SQL,
|
||||
[DisplayType.StackedBar]: TIME_CHART_EXAMPLE_SQL,
|
||||
[DisplayType.Table]: `WHERE Timestamp >= fromUnixTimestamp64Milli ({startDateMilliseconds:Int64})
|
||||
AND Timestamp <= fromUnixTimestamp64Milli ({endDateMilliseconds:Int64})`,
|
||||
[DisplayType.Pie]: '',
|
||||
[DisplayType.Number]: '',
|
||||
[DisplayType.Search]: '',
|
||||
[DisplayType.Heatmap]: '',
|
||||
[DisplayType.Markdown]: '',
|
||||
};
|
||||
|
||||
export function renderRawSqlChartConfig(
|
||||
chartConfig: RawSqlChartConfig & Partial<DateRange>,
|
||||
): ChSql {
|
||||
|
|
|
|||
Loading…
Reference in a new issue