diff --git a/.changeset/long-olives-relate.md b/.changeset/long-olives-relate.md new file mode 100644 index 00000000..a4d306e6 --- /dev/null +++ b/.changeset/long-olives-relate.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/app": patch +--- + +feat: Add raw sql line charts diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index cfb4fd78..1cfd6f03 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -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)}`, ); } diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 6c64a5e4..cc13344c 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -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) && ( - { - onUpdateChart?.({ - ...chart, - config: { - ...chart.config, - displayType, - }, - }); - }} - /> - )} + queriedConfig?.displayType === DisplayType.StackedBar) && ( + { + onUpdateChart?.({ + ...chart, + config: { + ...chart.config, + displayType, + }, + }); + }} + /> + )} {queriedConfig?.displayType === DisplayType.Table && ( { 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: [ diff --git a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx index 8165ed71..0c76aace 100644 --- a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx +++ b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx @@ -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 ( - - {value} - - clipboard.copy(value)} - > - {clipboard.copied ? : } - - - - — {description} - - - ); -} - -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 ( - - - - {helpOpened ? ( - - ) : ( - - )} - - Query parameters - - - - - - The following parameters can be referenced in this chart's SQL: - - - {availableParams.map(({ name, type, description }) => ( - - - - ))} - - Example: - - { - 'WHERE Timestamp >= fromUnixTimestamp64Milli ({startDateMilliseconds:Int64})\n AND Timestamp <= fromUnixTimestamp64Milli ({endDateMilliseconds:Int64})' - } - - - - - - ); -} - export default function RawSqlChartEditor({ control, setValue, @@ -166,7 +58,7 @@ export default function RawSqlChartEditor({ size="xs" /> - + + {value} + + clipboard.copy(value)} + > + {clipboard.copied ? : } + + + + — {description} + + + ); +} + +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 ( + + + + {helpOpened ? ( + + ) : ( + + )} + + SQL Chart Instructions + + + + + {(displayType === DisplayType.Line || + displayType === DisplayType.StackedBar) && ( + <> + + Result columns are plotted as follows: + + + + + Timestamp + + + {' '} + — The first Date or{' '} + DateTime column. + + + + + Series Value + + + {' '} + — Each numeric column will be plotted as a separate + series. These columns are generally aggregate function + values. + + + + + Group Names + + + {' '} + (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. + + + + + )} + + + The following parameters can be referenced in this chart's SQL: + + + {availableParams.map(({ name, type, description }) => ( + + + + ))} + + + + Example: + + + {QUERY_PARAM_EXAMPLES[displayType]} + + + + + + ); +} diff --git a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts index 6fedb74c..e0efc94b 100644 --- a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts +++ b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts @@ -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(); }); diff --git a/packages/app/src/components/ChartEditor/constants.ts b/packages/app/src/components/ChartEditor/constants.ts index 15246fe6..b04815ea 100644 --- a/packages/app/src/components/ChartEditor/constants.ts +++ b/packages/app/src/components/ChartEditor/constants.ts @@ -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.Line]: '', - [DisplayType.StackedBar]: '', + [DisplayType.Line]: TIMESERIES_PLACEHOLDER_SQL, + [DisplayType.StackedBar]: TIMESERIES_PLACEHOLDER_SQL, [DisplayType.Table]: `SELECT count() FROM diff --git a/packages/app/src/components/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index 42971c05..364e4ae5 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -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, [ diff --git a/packages/app/src/components/ChartSQLPreview.tsx b/packages/app/src/components/ChartSQLPreview.tsx index d6e4e160..0a4d3495 100644 --- a/packages/app/src/components/ChartSQLPreview.tsx +++ b/packages/app/src/components/ChartSQLPreview.tsx @@ -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 && } @@ -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 ( - - + + ); } diff --git a/packages/app/src/components/DBEditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm.tsx index d62cd169..6526e5a5 100644 --- a/packages/app/src/components/DBEditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm.tsx @@ -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) && ( )} - {alert && ( + {alert && !isRawSqlInput && ( @@ -1573,14 +1575,20 @@ export default function EditTimeChartForm({ /> )} - {queryReady && dbTimeChartConfig != null && activeTab === 'pie' && ( -
- -
- )} + {queryReady && + queriedConfig != null && + isBuilderChartConfig(queriedConfig) && + activeTab === 'pie' && ( +
+ +
+ )} {queryReady && queriedConfig != null && isBuilderChartConfig(queriedConfig) && @@ -1679,7 +1687,10 @@ export default function EditTimeChartForm({ {queryReady && chartConfigForExplanations != null && ( - + )} diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart.tsx index c0f83073..b7c3caad 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart.tsx @@ -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 ( +
+ + Error loading chart, please check your query or try again later. + + + errorExpansion.close()} + title="Error Details" + size="lg" + > + + + Error Message: + + + {error.message} + + {error instanceof ClickHouseQueryError && ( + <> + + Sent Query: + + + + )} + + +
+ ); +} + 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>( 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( , @@ -658,6 +729,7 @@ function DBTimeChartComponent({ return allToolbarItems; }, [ + builderQueriedConfig, config, displayType, handleSetDisplayType, @@ -678,48 +750,15 @@ function DBTimeChartComponent({ Loading Chart Data... ) : isError ? ( -
- - Error loading chart, please check your query or try again later. - - - errorExpansion.close()} - title="Error Details" - > - - - Error Message: - - - {error.message} - - {error instanceof ClickHouseQueryError && ( - <> - - Sent Query: - - - - )} - - -
+ + ) : resultFormattingError ? ( + ) : graphResults.length === 0 ? (
No data found within time range. diff --git a/packages/app/src/components/__tests__/DBTimeChart.test.tsx b/packages/app/src/components/__tests__/DBTimeChart.test.tsx index 68d9d1d0..39c66e03 100644 --- a/packages/app/src/components/__tests__/DBTimeChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTimeChart.test.tsx @@ -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(); + + 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(); + + // 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(), + ).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(), + ).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(), + ).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({ diff --git a/packages/app/src/hooks/useDashboardRefresh.tsx b/packages/app/src/hooks/useDashboardRefresh.tsx index adf4fbda..c8f6f29e 100644 --- a/packages/app/src/hooks/useDashboardRefresh.tsx +++ b/packages/app/src/hooks/useDashboardRefresh.tsx @@ -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, diff --git a/packages/common-utils/src/__tests__/rawSqlParams.test.ts b/packages/common-utils/src/__tests__/rawSqlParams.test.ts index fe5a3aea..e9bd25e1 100644 --- a/packages/common-utils/src/__tests__/rawSqlParams.test.ts +++ b/packages/common-utils/src/__tests__/rawSqlParams.test.ts @@ -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'); diff --git a/packages/common-utils/src/rawSqlParams.ts b/packages/common-utils/src/rawSqlParams.ts index e0eac87c..a55082d8 100644 --- a/packages/common-utils/src/rawSqlParams.ts +++ b/packages/common-utils/src/rawSqlParams.ts @@ -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) => any; }; +const getIntervalSeconds = (config: RawSqlChartConfig & Partial) => { + const granularity = config.granularity ?? 'auto'; + + const effectiveGranularity = + granularity === 'auto' && config.dateRange + ? convertDateRangeToGranularityString(config.dateRange) + : granularity; + + return convertGranularityToSeconds(effectiveGranularity); +}; + export const QUERY_PARAMS: Record = { startDateMilliseconds: { name: 'startDateMilliseconds', @@ -24,14 +39,37 @@ export const QUERY_PARAMS: Record = { get: (config: RawSqlChartConfig & Partial) => 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) => + 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.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, ): ChSql {