From 17a217bbb76569fc51a960ed873e5d940bee392d Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Mon, 28 Sep 2026 13:33:43 -0400 Subject: [PATCH 1/2] feat: Support PromQL table charts --- .changeset/promql-table-tiles.md | 6 + packages/app/src/ChartUtils.tsx | 36 ++- packages/app/src/__tests__/ChartUtils.test.ts | 34 ++ .../app/src/__tests__/prometheusApi.test.ts | 14 + packages/app/src/api.ts | 3 + .../components/ChartDisplaySettingsDrawer.tsx | 10 +- .../ChartEditor/__tests__/utils.test.ts | 22 ++ .../DBEditTimeChartForm/ChartActionBar.tsx | 3 +- .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 6 +- .../__tests__/ChartActionBar.test.tsx | 13 + packages/app/src/components/DBTableChart.tsx | 18 +- .../ChartDisplaySettingsDrawer.test.tsx | 37 +++ .../__tests__/DBTableChart.test.tsx | 96 ++++++ .../useOffsetPaginatedQuery.test.tsx | 53 +++- .../app/src/hooks/useOffsetPaginatedQuery.tsx | 17 + .../utils/__tests__/promqlChartQuery.test.ts | 299 ++++++++++++++++++ packages/app/src/utils/promqlChartQuery.ts | 82 ++++- .../tests/e2e/features/promql-table.spec.ts | 169 ++++++++++ .../src/core/__tests__/promql.test.ts | 32 ++ packages/common-utils/src/core/promql.ts | 4 +- 20 files changed, 934 insertions(+), 20 deletions(-) create mode 100644 .changeset/promql-table-tiles.md create mode 100644 packages/app/src/utils/__tests__/promqlChartQuery.test.ts create mode 100644 packages/app/tests/e2e/features/promql-table.spec.ts diff --git a/.changeset/promql-table-tiles.md b/.changeset/promql-table-tiles.md new file mode 100644 index 0000000000..b2b370b4a3 --- /dev/null +++ b/.changeset/promql-table-tiles.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/common-utils': patch +'@hyperdx/app': patch +--- + +feat: Support table tiles on PromQL sources diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index 50786b04ff..8ff78246c0 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -123,24 +123,19 @@ function getTimeChartDateRange( export const MAX_TIME_CHART_SERIES = DEFAULT_SERIES_LIMIT; /** - * A PromQL number tile's queried config, shared by the value and the sparkline - * drawn behind it. + * A PromQL config's resolved granularity, and its date range aligned to that + * granularity's buckets when it runs a range query, so its samples land on the + * same boundaries as the timeseries charts' and stay put across refreshes. */ -export function convertToPromqlNumberChartConfig( +function getAlignedRangeAndGranularity( config: PromqlChartConfig & DateRange, - { withReducer }: { withReducer: boolean }, -): PromqlChartConfig & DateRange { +): Pick { const granularity = getTimeChartGranularity( config.granularity, config.dateRange, ); return { - ...config, granularity, - // Align the date range with the same buckets used by the sparkline and - // timeseries charts, when using a (bucketed) range query. Alignment is - // desirable for range queries to ensure consistent bucket boundaries - // and sizes. dateRange: isRangeQuery(config) ? getTimeChartDateRange( config.dateRange, @@ -148,6 +143,20 @@ export function convertToPromqlNumberChartConfig( granularity, ) : config.dateRange, + }; +} + +/** + * A PromQL number tile's queried config, shared by the value and the sparkline + * drawn behind it. + */ +export function convertToPromqlNumberChartConfig( + config: PromqlChartConfig & DateRange, + { withReducer }: { withReducer: boolean }, +): PromqlChartConfig & DateRange { + return { + ...config, + ...getAlignedRangeAndGranularity(config), promqlExpression: getQueriedPromqlSeries(config).map(series => ({ ...series, reducer: withReducer @@ -157,6 +166,13 @@ export function convertToPromqlNumberChartConfig( }; } +/** A PromQL table tile's queried config. */ +export function convertToPromqlTableChartConfig( + config: PromqlChartConfig & DateRange, +): PromqlChartConfig & DateRange { + return { ...config, ...getAlignedRangeAndGranularity(config) }; +} + export function convertToTimeChartConfig( config: ChartConfigWithDateRange, ): ChartConfigWithDateRange { diff --git a/packages/app/src/__tests__/ChartUtils.test.ts b/packages/app/src/__tests__/ChartUtils.test.ts index 928b5727cb..165e8fe50b 100644 --- a/packages/app/src/__tests__/ChartUtils.test.ts +++ b/packages/app/src/__tests__/ChartUtils.test.ts @@ -11,6 +11,7 @@ import { ChartKeyJoiner, convertToNumberChartConfig, convertToPromqlNumberChartConfig, + convertToPromqlTableChartConfig, convertToTableChartConfig, convertToTimeChartConfig, findNearestSeriesKey, @@ -1284,6 +1285,39 @@ describe('ChartUtils', () => { }); }); + describe('convertToPromqlTableChartConfig', () => { + const promqlConfig = { + configType: 'promql' as const, + displayType: DisplayType.Table, + connection: 'conn', + promqlExpression: [{ expression: 'up' }, { expression: 'down' }], + dateRange: [ + new Date('2025-11-26T00:00:14.076Z'), + new Date('2025-11-26T01:00:14.076Z'), + ] as [Date, Date], + }; + + it('aligns a range query to its granularity, keeping every expression', () => { + const result = convertToPromqlTableChartConfig(promqlConfig); + + expect(result.dateRange).toEqual([ + new Date('2025-11-26T00:00:00Z'), + new Date('2025-11-26T01:01:00Z'), + ]); + expect(result.granularity).toBe('1 minute'); + expect(result.promqlExpression).toEqual(promqlConfig.promqlExpression); + }); + + it('leaves the date range unaligned for an instant query', () => { + const result = convertToPromqlTableChartConfig({ + ...promqlConfig, + promqlExpression: [{ expression: 'up', queryType: 'instant' }], + }); + + expect(result.dateRange).toEqual(promqlConfig.dateRange); + }); + }); + describe('convertToPromqlNumberChartConfig', () => { const promqlConfig = { configType: 'promql' as const, diff --git a/packages/app/src/__tests__/prometheusApi.test.ts b/packages/app/src/__tests__/prometheusApi.test.ts index 404729eb4c..26b3da6f16 100644 --- a/packages/app/src/__tests__/prometheusApi.test.ts +++ b/packages/app/src/__tests__/prometheusApi.test.ts @@ -136,6 +136,20 @@ describe('prometheusApi.query', () => { expect(params.has('step')).toBe(false); }); + it('forwards a series limit only when one is given', async () => { + await prometheusApi.query({ + query: 'up', + time: 1, + connectionId: 'conn', + limit: 25, + }); + expect(postedParams().get('limit')).toBe('25'); + + post.mockClear(); + await prometheusApi.query({ query: 'up', time: 1, connectionId: 'conn' }); + expect(postedParams().has('limit')).toBe(false); + }); + it('omits the table params a Prometheus-backed connection has no use for', async () => { await prometheusApi.query({ query: 'up', diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index 6112296748..08a83e0a32 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -902,6 +902,8 @@ export const prometheusApi = { connectionId: string; database?: string; table?: string; + /** Maximum number of series to return. */ + limit?: number; signal?: AbortSignal; }): Promise => prometheusFetch( @@ -910,6 +912,7 @@ export const prometheusApi = { query: params.query, time: String(params.time), connectionId: params.connectionId, + ...(params.limit ? { limit: String(params.limit) } : {}), ...(params.database ? { database: params.database } : {}), ...(params.table ? { table: params.table } : {}), }, diff --git a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx index a03b94a2e1..20f3ee0894 100644 --- a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx +++ b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx @@ -202,9 +202,12 @@ export default function ChartDisplaySettingsDrawer({ const showSeriesLimit = isTimeChart && configType !== 'promql'; const isRawSqlTimeChart = showSeriesLimit && configType === 'sql'; - // Every PromQL display except Number surfaces the series name + // Every PromQL display that surfaces a series name. A number tile shows one + // value and a table gives each label its own column, so neither has a legend. const showLegendTemplate = - configType === 'promql' && displayType !== DisplayType.Number; + configType === 'promql' && + displayType !== DisplayType.Number && + displayType !== DisplayType.Table; // On pie/bar builder charts, seriesLimit becomes a plain SQL LIMIT on the // number of slices/bars; raw SQL configs author their own LIMIT directly. @@ -218,7 +221,8 @@ export default function ChartDisplaySettingsDrawer({ // column ordering needs the builder `select` structure to know which columns // are group-by keys, so it stays builder-only. const showTableOptions = displayType === DisplayType.Table; - const showGroupByColumnsOnLeft = showTableOptions && configType !== 'sql'; + const showGroupByColumnsOnLeft = + showTableOptions && configType !== 'sql' && configType !== 'promql'; // Tile-level color is only meaningful for number tiles today. // Per-series colors on line / bar / pie ship in a follow-up PR via diff --git a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts index d31bd56168..805b42521f 100644 --- a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts +++ b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts @@ -701,6 +701,28 @@ describe('PromQL expressions', () => { }); }); + it('keeps the query type but drops the reducer on a table chart', () => { + expect( + convertFormStateToSavedChartConfig( + promqlForm( + [ + { + expression: 'up', + queryType: 'instant', + reducer: PromqlReducer.Max, + }, + ], + DisplayType.Table, + ), + undefined, + ), + ).toMatchObject({ + promqlExpression: [ + { expression: 'up', queryType: 'instant', reducer: undefined }, + ], + }); + }); + // A stale `instant` on a time series tile would hide its granularity picker, // and a reducer it never applies is dead weight in the saved config. it('drops the query type and reducer a time series chart cannot offer', () => { diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartActionBar.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartActionBar.tsx index bd6e6f8699..6b58c4aedc 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartActionBar.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartActionBar.tsx @@ -186,7 +186,8 @@ export function ChartActionBar({ {(activeTab === 'table' || activeTab === 'pie' || activeTab === 'bar') && - !isRawSqlInput && ( + !isRawSqlInput && + configType !== 'promql' && (
{ expect(screen.queryByTestId('sql-editor-order-by')).not.toBeInTheDocument(); }); + it('should not render ORDER BY editor for a PromQL table', () => { + renderActionBar( + { activeTab: 'table' }, + { + configType: 'promql', + displayType: DisplayType.Table, + promqlExpressions: [{ expression: 'up' }], + }, + ); + + expect(screen.queryByTestId('sql-editor-order-by')).not.toBeInTheDocument(); + }); + it('should not render ORDER BY editor for time tab', () => { renderActionBar({ activeTab: 'time' }); diff --git a/packages/app/src/components/DBTableChart.tsx b/packages/app/src/components/DBTableChart.tsx index 22180d26c6..1b13510540 100644 --- a/packages/app/src/components/DBTableChart.tsx +++ b/packages/app/src/components/DBTableChart.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; import cx from 'classnames'; +import { inferNumericColumn } from '@hyperdx/common-utils/dist/clickhouse'; import { isRatioChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig'; import { isBuilderChartConfig, @@ -16,6 +17,7 @@ import { SortingState } from '@tanstack/react-table'; import { buildMVDateRangeIndicator, + convertToPromqlTableChartConfig, convertToTableChartConfig, } from '@/ChartUtils'; import { Table, TableVariant } from '@/HDXMultiSeriesTableChart'; @@ -88,7 +90,9 @@ export default function DBTableChart({ const queriedConfig = useMemo(() => { if (isRawSqlChartConfig(config)) return config; - if (isPromqlChartConfig(config)) return config; + if (isPromqlChartConfig(config)) { + return convertToPromqlTableChartConfig(config); + } const _config = convertToTableChartConfig(config); @@ -206,6 +210,14 @@ export default function DBTableChart({ const seriesCount = getBuilderValueColumnCount(queriedConfig); const groupByCount = allKeys.length - seriesCount; groupByKeys = groupByCount > 0 ? allKeys.slice(-groupByCount) : []; + } else if (isPromqlChartConfig(queriedConfig)) { + // A PromQL table projects the sample value plus a column per Prometheus + // label (and a timestamp for range queries). Only the value column is + // numeric, so only it takes the tile's number format. + const numericKeys = new Set( + inferNumericColumn(data?.meta ?? [])?.map(column => column.name), + ); + groupByKeys = allKeys.filter(key => !numericKeys.has(key)); } // Builder table configs may opt to render Group By columns @@ -326,7 +338,9 @@ export default function DBTableChart({ isPlaceholderData || getRowAction ? undefined : getRowSearchLink } sorting={effectiveSort} - enableClientSideSorting={isRawSqlChartConfig(config)} + enableClientSideSorting={ + isRawSqlChartConfig(config) || isPromqlChartConfig(config) + } onSortingChange={handleSortingChange} variant={variant} alternateRowBackground={!!queriedConfig.alternateRowBackground} diff --git a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx index adb112c58e..cc0a906752 100644 --- a/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx +++ b/packages/app/src/components/__tests__/ChartDisplaySettingsDrawer.test.tsx @@ -486,6 +486,24 @@ describe('ChartDisplaySettingsDrawer', () => { }); }); + describe('display group by columns on left setting (PromQL)', () => { + it('does not show the toggle for PromQL table charts', () => { + renderWithMantine( + , + ); + + expect( + screen.queryByRole('checkbox', { + name: /display group by columns on left/i, + }), + ).not.toBeInTheDocument(); + }); + }); + describe('number format persistence', () => { // A duration number tile (e.g. p95 Duration from a trace source) auto-detects // a duration format from the datasource; the drawer receives it as @@ -578,6 +596,25 @@ describe('ChartDisplaySettingsDrawer', () => { displayType: DisplayType.Line, }; + it('is offered on a PromQL time series chart', () => { + renderWithMantine(); + + expect(screen.getByTestId('legend-template-input')).toBeInTheDocument(); + }); + + it('is hidden on a PromQL table chart', () => { + renderWithMantine( + , + ); + + expect( + screen.queryByTestId('legend-template-input'), + ).not.toBeInTheDocument(); + }); + it('blocks Apply when the template exceeds the persisted length cap', async () => { const onChange = jest.fn(); const onClose = jest.fn(); diff --git a/packages/app/src/components/__tests__/DBTableChart.test.tsx b/packages/app/src/components/__tests__/DBTableChart.test.tsx index 9063f7744e..260b034db9 100644 --- a/packages/app/src/components/__tests__/DBTableChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTableChart.test.tsx @@ -490,6 +490,102 @@ describe('DBTableChart', () => { }); }); + describe('PromQL configs', () => { + const promqlConfig = { + configType: 'promql' as const, + dateRange: [new Date(), new Date()] as [Date, Date], + connection: 'test-connection', + promqlExpression: [{ expression: 'up' }], + numberFormat: { output: 'number' as const }, + }; + + const queryResult = (valueType: string) => ({ + data: { + data: [{ Time: '2023-11-14T22:13:20.000Z', service: 'web', Value: 1 }], + meta: [ + { name: 'Time', type: 'DateTime64(3)' }, + { name: 'service', type: 'String' }, + { name: 'Value', type: valueType }, + ], + chSql: { sql: '', params: {} }, + window: { + startTime: new Date(), + endTime: new Date(), + windowIndex: 0, + direction: 'DESC' as const, + }, + }, + fetchNextPage: jest.fn(), + hasNextPage: false, + isLoading: false, + isFetching: false, + isError: false, + isPlaceholderData: false, + error: null, + }); + + beforeEach(() => { + jest + .mocked(useOffsetPaginatedQuery) + .mockReturnValue(queryResult('Float64')); + }); + + it('queries a range aligned to a resolved granularity', () => { + renderWithMantine( + , + ); + + const queried = jest + .mocked(useOffsetPaginatedQuery) + .mock.calls.at(-1)![0]; + expect(queried.dateRange).toEqual([ + new Date('2025-11-26T00:00:00Z'), + new Date('2025-11-26T01:01:00Z'), + ]); + expect(queried.granularity).toBe('1 minute'); + }); + + it('sorts client-side, since the query cannot be re-ordered server-side', () => { + renderWithMantine(); + + expect( + jest.mocked(Table).mock.calls.at(-1)![0].enableClientSideSorting, + ).toBe(true); + }); + + it('formats only the value column, leaving the label columns alone', () => { + renderWithMantine(); + + const columns = jest.mocked(Table).mock.calls.at(-1)![0].columns; + expect(columns.map(c => [c.dataKey, c.numberFormat?.output])).toEqual([ + ['Time', undefined], + ['service', undefined], + ['Value', 'number'], + ]); + }); + + it('formats a value column of any numeric type', () => { + jest + .mocked(useOffsetPaginatedQuery) + .mockReturnValue(queryResult('Nullable(Float64)')); + + renderWithMantine(); + + const columns = jest.mocked(Table).mock.calls.at(-1)![0].columns; + expect(columns.find(c => c.dataKey === 'Value')?.numberFormat).toEqual({ + output: 'number', + }); + }); + }); + it('does not render DateRangeIndicator when MV optimization has no optimized date range', () => { // Mock useMVOptimizationExplanation to return data without an optimized config jest.mocked(useMVOptimizationExplanation).mockReturnValue({ diff --git a/packages/app/src/hooks/__tests__/useOffsetPaginatedQuery.test.tsx b/packages/app/src/hooks/__tests__/useOffsetPaginatedQuery.test.tsx index 60935680f2..d6389199c0 100644 --- a/packages/app/src/hooks/__tests__/useOffsetPaginatedQuery.test.tsx +++ b/packages/app/src/hooks/__tests__/useOffsetPaginatedQuery.test.tsx @@ -1,6 +1,9 @@ import React, { act } from 'react'; import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse'; -import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; +import { + ChartConfigWithDateRange, + PromqlConfigWithDateRange, +} from '@hyperdx/common-utils/dist/types'; import { QueryClient, QueryClientProvider, @@ -58,6 +61,10 @@ jest.mock('@hyperdx/common-utils/dist/core/renderChartConfig', () => ({ renderChartConfig: jest.fn(), })); +jest.mock('@/utils/promqlChartQuery', () => ({ + queryPromqlChartConfig: jest.fn(), +})); + // Import mocked modules after jest.mock calls import { getClickhouseClient } from '@hyperdx/app/src/clickhouse'; import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig'; @@ -68,6 +75,7 @@ import { MVOptimizationExplanationResult, useMVOptimizationExplanation, } from '@/hooks/useMVOptimizationExplanation'; +import { queryPromqlChartConfig } from '@/utils/promqlChartQuery'; // Create a mock ChartConfig based on the Zod schema const createMockChartConfig = ( @@ -1182,6 +1190,49 @@ describe('useOffsetPaginatedQuery', () => { }); }); + // PromQL never reaches ClickHouse: the Prometheus API route answers in one + // page, which is also why getNextPageParam refuses to paginate it. + describe('PromQL configs', () => { + const promqlConfig: PromqlConfigWithDateRange = { + configType: 'promql', + connection: 'foo', + promqlExpression: [{ expression: 'up' }], + dateRange: [ + new Date('2024-01-01T00:00:00Z'), + new Date('2024-01-02T00:00:00Z'), + ], + }; + + it('queries Prometheus instead of rendering SQL', async () => { + jest.mocked(queryPromqlChartConfig).mockResolvedValue({ + data: [{ service: 'web', Value: 1 }], + meta: [ + { name: 'service', type: 'String' }, + { name: 'Value', type: 'Float64' }, + ], + rows: 1, + isComplete: true, + }); + + const { result } = renderHook( + () => useOffsetPaginatedQuery(promqlConfig), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(queryPromqlChartConfig).toHaveBeenCalledWith( + promqlConfig, + promqlConfig.dateRange, + expect.anything(), + ); + expect(renderChartConfig).not.toHaveBeenCalled(); + expect(mockClickhouseClient.query).not.toHaveBeenCalled(); + expect(result.current.data?.data).toEqual([{ service: 'web', Value: 1 }]); + expect(result.current.hasNextPage).toBe(false); + }); + }); + describe('MV Optimization Integration', () => { it('should optimize queries using MVs when possible', async () => { const config = createMockChartConfig({ diff --git a/packages/app/src/hooks/useOffsetPaginatedQuery.tsx b/packages/app/src/hooks/useOffsetPaginatedQuery.tsx index 32f635ee2c..0197af81a4 100644 --- a/packages/app/src/hooks/useOffsetPaginatedQuery.tsx +++ b/packages/app/src/hooks/useOffsetPaginatedQuery.tsx @@ -39,6 +39,7 @@ import { MAX_TABLE_ROWS } from '@/HDXMultiSeriesTableChart'; import { useMetadataWithSettings } from '@/hooks/useMetadata'; import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation'; import { useSource } from '@/source'; +import { queryPromqlChartConfig } from '@/utils/promqlChartQuery'; import { DEFAULT_TIME_WINDOWS_SECONDS, generateTimeWindowsAscending, @@ -215,6 +216,22 @@ const queryFn: QueryFunction = async ({ direction: 'DESC' as const, }; + // PromQL goes to the Prometheus API route rather than ClickHouse, and never + // paginates (see getNextPageParam), so it answers in a single page. + if (isPromqlChartConfig(config)) { + const { data, meta } = await queryPromqlChartConfig( + config, + config.dateRange, + signal, + ); + return { + data, + meta: meta ?? [], + chSql: { sql: '', params: {} }, + window: timeWindow, + }; + } + // Create config with windowed date range const windowedConfig = isBuilderChartConfig(config) ? { diff --git a/packages/app/src/utils/__tests__/promqlChartQuery.test.ts b/packages/app/src/utils/__tests__/promqlChartQuery.test.ts new file mode 100644 index 0000000000..b1bcf0c624 --- /dev/null +++ b/packages/app/src/utils/__tests__/promqlChartQuery.test.ts @@ -0,0 +1,299 @@ +import { DisplayType } from '@hyperdx/common-utils/dist/types'; + +import { prometheusApi } from '@/api'; +import { queryPromqlChartConfig, toTableRows } from '@/utils/promqlChartQuery'; + +// The real module is a Mantine/TanStack table component; only the row cap is +// needed here. +jest.mock('@/HDXMultiSeriesTableChart', () => ({ + MAX_TABLE_ROWS: 10_000, +})); + +jest.mock('@/api', () => ({ + prometheusApi: { query: jest.fn(), queryRange: jest.fn() }, +})); + +const instantResult = ( + metric: Record, + ts: number, + value: number, +) => ({ + expression: 'up', + isBucketed: false, + result: [{ metric, values: [[ts, value]] as [number, number][] }], +}); + +describe('toTableRows', () => { + it('projects one row per series for an instant query, with no time column', () => { + const result = toTableRows([ + { + expression: 'up', + isBucketed: false, + result: [ + { + metric: { __name__: 'up', service: 'accounting' }, + values: [[1700000000, 1]], + }, + { + metric: { __name__: 'up', service: 'billing' }, + values: [[1700000000, 0]], + }, + ], + }, + ]); + + expect(result.data).toEqual([ + { __name__: 'up', service: 'accounting', Value: 1 }, + { __name__: 'up', service: 'billing', Value: 0 }, + ]); + expect(result.meta).toEqual([ + { name: '__name__', type: 'String' }, + { name: 'service', type: 'String' }, + { name: 'Value', type: 'Float64' }, + ]); + expect(result.rows).toBe(2); + }); + + it('projects one row per sample for a range query, with a time column first', () => { + const result = toTableRows([ + { + expression: 'up', + isBucketed: true, + result: [ + { + metric: { service: 'accounting' }, + values: [ + [1700000000, 1], + [1700000060, 2], + ], + }, + ], + }, + ]); + + expect(result.data).toEqual([ + { + Timestamp: '2023-11-14T22:13:20.000Z', + service: 'accounting', + Value: 1, + }, + { + Timestamp: '2023-11-14T22:14:20.000Z', + service: 'accounting', + Value: 2, + }, + ]); + expect(Object.keys(result.data[0])).toEqual([ + 'Timestamp', + 'service', + 'Value', + ]); + expect(result.meta?.[0]).toEqual({ + name: 'Timestamp', + type: 'DateTime64(3)', + }); + }); + + it('blanks a label the series does not carry, and orders __name__ first', () => { + const result = toTableRows([ + { + expression: 'up', + isBucketed: false, + result: [ + { metric: { pod: 'a', __name__: 'up' }, values: [[1, 1]] }, + { metric: { service: 'billing' }, values: [[1, 2]] }, + ], + }, + ]); + + expect(result.data).toEqual([ + { __name__: 'up', pod: 'a', service: '', Value: 1 }, + { __name__: '', pod: '', service: 'billing', Value: 2 }, + ]); + }); + + it('renames the reserved columns when a label already uses them', () => { + const result = toTableRows([ + { + expression: 'up', + isBucketed: true, + result: [ + { + metric: { Timestamp: 'noon', Value: 'high' }, + values: [[1700000000, 7]], + }, + ], + }, + ]); + + expect(result.data).toEqual([ + { + Timestamp_1: '2023-11-14T22:13:20.000Z', + Timestamp: 'noon', + Value: 'high', + Value_1: 7, + }, + ]); + expect(result.meta).toEqual([ + { name: 'Timestamp_1', type: 'DateTime64(3)' }, + { name: 'Timestamp', type: 'String' }, + { name: 'Value', type: 'String' }, + { name: 'Value_1', type: 'Float64' }, + ]); + }); + + it('caps the rows it returns', () => { + const result = toTableRows( + [ + { + expression: 'up', + isBucketed: true, + result: [ + { + metric: { service: 'accounting' }, + values: Array.from( + { length: 10 }, + (_, i) => [1700000000 + i, i] as [number, number], + ), + }, + ], + }, + ], + 3, + ); + + expect(result.data).toHaveLength(3); + expect(result.rows).toBe(3); + }); + + it('unions the labels across expressions', () => { + const result = toTableRows([ + instantResult({ service: 'accounting' }, 1, 1), + instantResult({ pod: 'a' }, 1, 2), + ]); + + expect(result.data).toEqual([ + { pod: '', service: 'accounting', Value: 1 }, + { pod: 'a', service: '', Value: 2 }, + ]); + }); + + it('returns only a value column when there are no expressions', () => { + expect(toTableRows([])).toEqual({ + data: [], + meta: [{ name: 'Value', type: 'Float64' }], + rows: 0, + isComplete: true, + }); + }); + + it('returns only a value column for an instant query that matched nothing', () => { + expect( + toTableRows([{ expression: 'up', isBucketed: false, result: [] }]), + ).toEqual({ + data: [], + meta: [{ name: 'Value', type: 'Float64' }], + rows: 0, + isComplete: true, + }); + }); + + // The columns come from the series, not the samples, so an empty range + // result still describes the table it would have filled. + it('keeps the columns of a range series that has no samples', () => { + const result = toTableRows([ + { + expression: 'up', + isBucketed: true, + result: [{ metric: { service: 'web' }, values: [] }], + }, + ]); + + expect(result.data).toEqual([]); + expect(result.rows).toBe(0); + expect(result.meta).toEqual([ + { name: 'Timestamp', type: 'DateTime64(3)' }, + { name: 'service', type: 'String' }, + { name: 'Value', type: 'Float64' }, + ]); + }); + + it('handles label keys that collide with Object.prototype members', () => { + const result = toTableRows([ + instantResult(JSON.parse('{"constructor":"a","__proto__":"b"}'), 1, 1), + instantResult({ pod: 'x' }, 1, 2), + ]); + + expect(result.meta?.map(({ name }) => name)).toEqual([ + '__proto__', + 'constructor', + 'pod', + 'Value', + ]); + expect(Object.entries(result.data[0])).toEqual([ + ['__proto__', 'b'], + ['constructor', 'a'], + ['pod', ''], + ['Value', 1], + ]); + expect(Object.entries(result.data[1])).toEqual([ + ['__proto__', ''], + ['constructor', ''], + ['pod', 'x'], + ['Value', 2], + ]); + }); +}); + +describe('queryPromqlChartConfig', () => { + const dateRange: [Date, Date] = [ + new Date('2024-01-01T00:00:00Z'), + new Date('2024-01-01T01:00:00Z'), + ]; + + beforeEach(() => { + jest + .mocked(prometheusApi.query) + .mockReset() + .mockResolvedValue({ + status: 'success', + data: { resultType: 'vector', result: [] }, + }); + }); + + it("caps an instant table query's series at the table's row cap", async () => { + await queryPromqlChartConfig( + { + configType: 'promql', + displayType: DisplayType.Table, + connection: 'conn', + promqlExpression: [{ expression: 'up', queryType: 'instant' }], + }, + dateRange, + new AbortController().signal, + ); + + expect(prometheusApi.query).toHaveBeenCalledWith( + expect.objectContaining({ limit: 10_000 }), + ); + }); + + // A number tile warns when an expression yields several series, so it needs + // to see all of them. + it('leaves an instant number query uncapped', async () => { + await queryPromqlChartConfig( + { + configType: 'promql', + displayType: DisplayType.Number, + connection: 'conn', + promqlExpression: [{ expression: 'up', queryType: 'instant' }], + }, + dateRange, + new AbortController().signal, + ); + + expect(prometheusApi.query).toHaveBeenCalledWith( + expect.objectContaining({ limit: undefined }), + ); + }); +}); diff --git a/packages/app/src/utils/promqlChartQuery.ts b/packages/app/src/utils/promqlChartQuery.ts index c17f193223..6654a0188c 100644 --- a/packages/app/src/utils/promqlChartQuery.ts +++ b/packages/app/src/utils/promqlChartQuery.ts @@ -7,6 +7,7 @@ import { } from '@hyperdx/common-utils/dist/core/promql'; import { renderPromqlSeriesNames } from '@hyperdx/common-utils/dist/core/seriesNameTemplate'; import { + DisplayType, PrometheusMatrixResult, PromqlChartConfig, PromqlReducer, @@ -15,6 +16,7 @@ import { import { substitutePromqlChartConfigVariables } from '@hyperdx/common-utils/dist/variables'; import { prometheusApi, PrometheusInstantQueryResponse } from '@/api'; +import { MAX_TABLE_ROWS } from '@/HDXMultiSeriesTableChart'; import { ChartQueryResult } from '@/types'; /** One series of a result: its labels, and its `[unix seconds, value]` samples. */ @@ -86,6 +88,11 @@ async function fetchInstantExpression( query: series.expression, time: evalTime.getTime() / 1000, connectionId: config.connection, + // A vector is one sample per series, so capping series caps a table's + // rows exactly. Range queries go uncapped: series can be sparse, so no + // series count bounds their samples without dropping rows that would fit. + limit: + config.displayType === DisplayType.Table ? MAX_TABLE_ROWS : undefined, database: config.from?.databaseName, table: config.from?.tableName, signal, @@ -174,6 +181,77 @@ function toChartRows( }; } +/** `base`, or `base_N` with the lowest N that doesn't collide with a label column. */ +function uniqueColumnName(base: string, taken: Set): string { + if (!taken.has(base)) return base; + let n = 1; + while (taken.has(`${base}_${n}`)) n++; + return `${base}_${n}`; +} + +/** Every label any series carries, `__name__` first and the rest alphabetical. */ +function labelColumns(expressions: ExpressionResult[]): string[] { + const keys = new Set(); + for (const { result } of expressions) { + for (const { metric } of result) { + for (const key of Object.keys(metric)) keys.add(key); + } + } + const hasName = keys.delete('__name__'); + return [...(hasName ? ['__name__'] : []), ...[...keys].sort()]; +} + +/** Every sample of every series, lazily, so a capped caller stops reading early. */ +function* samples(expressions: ExpressionResult[]) { + for (const { result } of expressions) { + for (const { metric, values } of result) { + for (const [ts, value] of values) yield { metric, ts, value }; + } + } +} + +/** + * Rows for a table tile: one per sample, with a column per Prometheus label, + * including __name__, plus Timestamp and Value column. Every row carries the + * same keys in the same order. + */ +export function toTableRows( + expressions: ExpressionResult[], + maxRows: number = MAX_TABLE_ROWS, +): ChartQueryResult { + const labels = labelColumns(expressions); + const taken = new Set(labels); + const timeColumn = expressions.some(({ isBucketed }) => isBucketed) + ? uniqueColumnName('Timestamp', taken) + : undefined; + if (timeColumn) taken.add(timeColumn); + const valueColumn = uniqueColumnName('Value', taken); + + // Cap the total number of rows before the rows end up in the react-query cache. + const data: Record[] = []; + for (const { metric, ts, value } of samples(expressions)) { + if (data.length >= maxRows) break; + const row: [string, string | number][] = []; + if (timeColumn) row.push([timeColumn, new Date(ts * 1000).toISOString()]); + for (const label of labels) { + row.push([label, Object.hasOwn(metric, label) ? metric[label] : '']); + } + row.push([valueColumn, value]); + data.push(Object.fromEntries(row)); + } + + return { + data, + meta: [ + ...(timeColumn ? [{ name: timeColumn, type: 'DateTime64(3)' }] : []), + ...labels.map(name => ({ name, type: 'String' })), + { name: valueColumn, type: 'Float64' }, + ], + rows: data.length, + isComplete: true, + }; +} + /** * Run a PromQL tile's expressions and shape the result like a ClickHouse * response, so the chart formatters treat it like every other source. @@ -195,7 +273,9 @@ export async function queryPromqlChartConfig( ), ); - return toChartRows(results, substituted.legendTemplate?.trim() || undefined); + return substituted.displayType === DisplayType.Table + ? toTableRows(results) + : toChartRows(results, substituted.legendTemplate?.trim() || undefined); } /** diff --git a/packages/app/tests/e2e/features/promql-table.spec.ts b/packages/app/tests/e2e/features/promql-table.spec.ts new file mode 100644 index 0000000000..cb49078413 --- /dev/null +++ b/packages/app/tests/e2e/features/promql-table.spec.ts @@ -0,0 +1,169 @@ +/** + * A PromQL table tile gives every Prometheus label its own column and sorts + * client-side. A range query lists every sample — there is no reducer here, so + * nothing collapses them — while an instant query lists one row per series and + * has no time column at all. The tile's number format applies to the value + * column only, and the columns survive a save and a reload. + * + * The seed gives `e2e_service_up` one series per `SERVICES` entry, labelled + * `service`, one sample a minute. + */ +import { DisplayType } from '@hyperdx/common-utils/dist/types'; + +import { DashboardPage } from '../page-objects/DashboardPage'; +import { SERVICES } from '../seed-clickhouse'; +import { expect, test } from '../utils/base-test'; +import { E2E_PROMQL_METRIC_NAME, PROMQL_SOURCE_NAME } from '../utils/constants'; + +/** + * Every seeded series. Written as a matcher rather than the bare metric name so + * the editor's autocomplete popup closes on the trailing brace — left open it + * covers the expression's own query type control. + */ +const ALL_SERIES = `${E2E_PROMQL_METRIC_NAME}{service!=""}`; + +/** Case-insensitive, matching how the table sorts a string column. */ +const BY_NAME = [...SERVICES].sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()), +); + +test.describe( + 'PromQL table tiles', + { tag: ['@dashboard', '@full-stack'] }, + () => { + test('projects labels as sortable columns for range and instant queries', async ({ + page, + }) => { + test.setTimeout(120000); + + const dashboardPage = new DashboardPage(page); + const editor = dashboardPage.chartEditor; + const timeColumn = page.getByRole('columnheader', { + name: 'Timestamp', + exact: true, + }); + const serviceColumn = page.getByRole('columnheader', { + name: 'service', + exact: true, + }); + // The editor page can hold more than one table; pin the one carrying the + // PromQL label column. + const table = page + .getByRole('table') + .filter({ has: serviceColumn }) + .first(); + + await test.step('Create a PromQL table tile', async () => { + await dashboardPage.goto(); + await dashboardPage.createNewDashboard(); + await dashboardPage.addTile(); + await expect(editor.nameInput).toBeVisible(); + await editor.waitForDataToLoad(); + await editor.switchToPromqlMode(); + await editor.selectPromqlSource(PROMQL_SOURCE_NAME); + await editor.setChartName('PromQL table tile'); + await editor.setChartType(DisplayType.Table); + await editor.replacePromqlExpression(ALL_SERIES); + await editor.runQuery(false); + }); + + await test.step('A range query lists every sample, with a time column', async () => { + await expect(timeColumn).toBeVisible({ timeout: 30000 }); + await expect(serviceColumn).toBeVisible(); + await expect( + page.getByRole('columnheader', { name: '__name__', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('columnheader', { name: 'Value', exact: true }), + ).toBeVisible(); + // A PromQL result is sorted by clicking a column, not a SQL clause. + await expect(page.getByTestId('order-by-input')).toBeHidden(); + // One sample a minute per service, so there are more rows than series + // even before scrolling the virtualized table. + await expect(table.getByRole('row').nth(SERVICES.length)).toBeVisible(); + }); + + await test.step('An instant query drops the time column and lists one row per series', async () => { + await editor.setPromqlQueryType('Instant'); + await editor.runQuery(false); + + await expect(timeColumn).toBeHidden({ timeout: 30000 }); + await expect(serviceColumn).toBeVisible(); + // Filtered rather than counting every row: the virtualizer renders + // spacer rows around the data ones. + await expect( + table.getByRole('row').filter({ hasText: E2E_PROMQL_METRIC_NAME }), + ).toHaveCount(SERVICES.length); + }); + + await test.step('Clicking a label column sorts client-side, both ways', async () => { + const firstRow = () => + table + .getByRole('row') + .filter({ hasText: E2E_PROMQL_METRIC_NAME }) + .first(); + + await serviceColumn.click(); + await expect( + firstRow().getByRole('cell', { name: BY_NAME[0], exact: true }), + ).toBeVisible(); + + await serviceColumn.click(); + await expect( + firstRow().getByRole('cell', { + name: BY_NAME[BY_NAME.length - 1], + exact: true, + }), + ).toBeVisible(); + }); + + await test.step('The tile offers no legend template or group-by placement', async () => { + await editor.openDisplaySettings(); + await expect(page.getByTestId('legend-template-input')).toBeHidden(); + await expect( + page + .getByRole('dialog', { name: 'Display Settings' }) + .getByLabel('Display Group By Columns on Left'), + ).toBeHidden(); + await editor.setNumberFormatOutput('Currency'); + await editor.applyDisplaySettings(); + }); + + const expectSavedTile = async () => { + await expect + .poll( + async () => + (await dashboardPage.getTileTableHeaders(0)).filter(Boolean), + { timeout: 30000 }, + ) + .toEqual(['__name__', 'service', 'Value']); + + // Polled: the tile briefly shows its previous render after a save. + await expect + .poll( + async () => { + const values = await dashboardPage.getTileTableCellTexts(0, 2); + return values.length > 0 && values.every(v => v.includes('$')); + }, + { timeout: 15000 }, + ) + .toBe(true); + const services = await dashboardPage.getTileTableCellTexts(0, 1); + expect([...services].sort()).toEqual([...SERVICES].sort()); + }; + + await test.step('The saved tile keeps its columns and formats only the value', async () => { + await editor.save(); + await expect(dashboardPage.getTiles()).toHaveCount(1, { + timeout: 10000, + }); + await expectSavedTile(); + }); + + await test.step('The tile renders the same after a reload', async () => { + await dashboardPage.reload(); + await expectSavedTile(); + }); + }); + }, +); diff --git a/packages/common-utils/src/core/__tests__/promql.test.ts b/packages/common-utils/src/core/__tests__/promql.test.ts index c480828071..349929f703 100644 --- a/packages/common-utils/src/core/__tests__/promql.test.ts +++ b/packages/common-utils/src/core/__tests__/promql.test.ts @@ -1,4 +1,6 @@ import { + displayTypeSupportsInstantQuery, + displayTypeSupportsReducer, getPromqlSeries, getQueriedPromqlSeries, isRangeQuery, @@ -103,6 +105,36 @@ describe('getQueriedPromqlSeries', () => { }); }); +describe('displayTypeSupportsInstantQuery', () => { + it.each([DisplayType.Number, DisplayType.Table])( + 'offers an instant query on %s tiles', + displayType => { + expect(displayTypeSupportsInstantQuery({ displayType })).toBe(true); + }, + ); + + it.each([DisplayType.Line, DisplayType.StackedBar, DisplayType.Pie])( + 'always range-queries %s tiles', + displayType => { + expect(displayTypeSupportsInstantQuery({ displayType })).toBe(false); + }, + ); +}); + +describe('displayTypeSupportsReducer', () => { + it('reduces range buckets on number tiles', () => { + expect( + displayTypeSupportsReducer({ displayType: DisplayType.Number }), + ).toBe(true); + }); + + it('keeps every sample on table tiles', () => { + expect(displayTypeSupportsReducer({ displayType: DisplayType.Table })).toBe( + false, + ); + }); +}); + describe('promqlSeriesQueryType', () => { it('defaults to a range query', () => { expect(promqlSeriesQueryType({ expression: 'up' })).toBe('range'); diff --git a/packages/common-utils/src/core/promql.ts b/packages/common-utils/src/core/promql.ts index e60a4c4830..d7837009d2 100644 --- a/packages/common-utils/src/core/promql.ts +++ b/packages/common-utils/src/core/promql.ts @@ -45,7 +45,9 @@ export function getQueriedPromqlSeries(config: { /** Whether a display type can evaluate an expression using query instead of query_range */ export const displayTypeSupportsInstantQuery = (config: { displayType?: DisplayType; -}): boolean => config.displayType === DisplayType.Number; +}): boolean => + config.displayType === DisplayType.Number || + config.displayType === DisplayType.Table; /** Whether a display type collapses a range query to one value per series using a client-side reducer. */ export const displayTypeSupportsReducer = (config: { From f2143092ec330ae566065ce8c4596023fc6d4db2 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Tue, 29 Sep 2026 08:19:19 -0400 Subject: [PATCH 2/2] test: Fix test after merge --- packages/app/tests/e2e/features/promql-table.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/tests/e2e/features/promql-table.spec.ts b/packages/app/tests/e2e/features/promql-table.spec.ts index cb49078413..fb851267be 100644 --- a/packages/app/tests/e2e/features/promql-table.spec.ts +++ b/packages/app/tests/e2e/features/promql-table.spec.ts @@ -60,7 +60,7 @@ test.describe( await expect(editor.nameInput).toBeVisible(); await editor.waitForDataToLoad(); await editor.switchToPromqlMode(); - await editor.selectPromqlSource(PROMQL_SOURCE_NAME); + await editor.selectSource(PROMQL_SOURCE_NAME); await editor.setChartName('PromQL table tile'); await editor.setChartType(DisplayType.Table); await editor.replacePromqlExpression(ALL_SERIES);