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:
Drew Davis 2026-03-10 14:05:47 -04:00 committed by GitHub
parent 2efb8fdc52
commit 1e6fcf1c02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 814 additions and 276 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
---
feat: Add raw sql line charts

View file

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

View file

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

View file

@ -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';

View file

@ -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: [

View file

@ -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">
&mdash; {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}

View file

@ -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">
&mdash; {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>
);
}

View file

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

View file

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

View file

@ -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, [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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