Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/promql-table-tiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hyperdx/common-utils': patch
'@hyperdx/app': patch
---

feat: Support table tiles on PromQL sources
36 changes: 26 additions & 10 deletions packages/app/src/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,31 +123,40 @@ 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<PromqlChartConfig & DateRange, 'granularity' | 'dateRange'> {
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,
config.alignDateRangeToGranularity,
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
Expand All @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions packages/app/src/__tests__/ChartUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ChartKeyJoiner,
convertToNumberChartConfig,
convertToPromqlNumberChartConfig,
convertToPromqlTableChartConfig,
convertToTableChartConfig,
convertToTimeChartConfig,
findNearestSeriesKey,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/app/src/__tests__/prometheusApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,8 @@ export const prometheusApi = {
connectionId: string;
database?: string;
table?: string;
/** Maximum number of series to return. */
limit?: number;
signal?: AbortSignal;
}): Promise<PrometheusInstantQueryResponse> =>
prometheusFetch(
Expand All @@ -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 } : {}),
},
Expand Down
10 changes: 7 additions & 3 deletions packages/app/src/components/ChartDisplaySettingsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions packages/app/src/components/ChartEditor/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ export function ChartActionBar({
{(activeTab === 'table' ||
activeTab === 'pie' ||
activeTab === 'bar') &&
!isRawSqlInput && (
!isRawSqlInput &&
configType !== 'promql' && (
<div style={{ width: 400 }} data-testid="order-by-input">
<SQLInlineEditorControlled
parentRef={parentRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,11 @@ export function ChartPreviewPanel({
})
: undefined
}
onSortingChange={onTableSortingChange}
onSortingChange={
isBuilderChartConfig(queriedConfig)
? onTableSortingChange
: undefined
}
sort={tableSortState}
showMVOptimizationIndicator={false}
errorVariant="inline"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,19 @@ describe('ChartActionBar', () => {
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' });

Expand Down
18 changes: 16 additions & 2 deletions packages/app/src/components/DBTableChart.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,6 +17,7 @@ import { SortingState } from '@tanstack/react-table';

import {
buildMVDateRangeIndicator,
convertToPromqlTableChartConfig,
convertToTableChartConfig,
} from '@/ChartUtils';
import { Table, TableVariant } from '@/HDXMultiSeriesTableChart';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ChartDisplaySettingsDrawer
{...baseProps}
configType="promql"
displayType={DisplayType.Table}
/>,
);

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
Expand Down Expand Up @@ -578,6 +596,25 @@ describe('ChartDisplaySettingsDrawer', () => {
displayType: DisplayType.Line,
};

it('is offered on a PromQL time series chart', () => {
renderWithMantine(<ChartDisplaySettingsDrawer {...promqlProps} />);

expect(screen.getByTestId('legend-template-input')).toBeInTheDocument();
});

it('is hidden on a PromQL table chart', () => {
renderWithMantine(
<ChartDisplaySettingsDrawer
{...promqlProps}
displayType={DisplayType.Table}
/>,
);

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();
Expand Down
Loading
Loading