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/adhoc-filter-broadcast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hyperdx/common-utils': patch
'@hyperdx/app': patch
---

feat: Broadcast adhoc dashboard filter conditions
46 changes: 34 additions & 12 deletions packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import {
isTimeSeriesDisplayType,
} from '@hyperdx/common-utils/dist/core/utils';
import { getBlockingRequiredFilterNames } from '@hyperdx/common-utils/dist/dashboardFilterValues';
import {
getPromqlFilters,
getSqlFilters,
} from '@hyperdx/common-utils/dist/filters';
import {
displayTypeRequiresSource,
isBuilderChartConfig,
Expand All @@ -51,12 +55,12 @@ import {
import { isMissingFiltersMacro } from '@hyperdx/common-utils/dist/macros';
import {
AlertState,
BroadcastFilter,
ChartConfigWithDateRange,
ChartVariable,
DashboardContainer as DashboardContainerSchema,
DashboardFilter,
DisplayType,
Filter,
getSampleWeightExpression,
isLogSource,
isTraceSource,
Expand Down Expand Up @@ -467,7 +471,7 @@ const Tile = ({
onSettled?: () => void;
granularity: SQLInterval | undefined;
onTimeRangeSelect: (start: Date, end: Date) => void;
filters?: Filter[];
filters?: BroadcastFilter[];
variables?: ChartVariable[];
/** The dashboard's required filters that have nothing selected. */
unsatisfiedRequiredFilters?: DashboardFilter[];
Expand Down Expand Up @@ -650,6 +654,15 @@ const Tile = ({
missingRequiredFilterNames.length > 0 &&
displayTypeRequiresSource(chart.config.displayType);

const sqlFilters = useMemo(
() => (filters ? getSqlFilters(filters) : undefined),
[filters],
);
const promqlFilters = useMemo(
() => (filters ? getPromqlFilters(filters) : undefined),
[filters],
);

useEffect(() => {
if (isPromqlSavedChartConfig(chart.config)) {
if (source != null) {
Expand All @@ -660,6 +673,7 @@ const Tile = ({
dateRange,
granularity,
variables: tileVariables,
filters: promqlFilters,
});
}
return;
Expand All @@ -672,7 +686,7 @@ const Tile = ({
...chart.config,
dateRange,
granularity,
filters,
filters: sqlFilters,
variables: tileVariables,
});
} else if (source != null) {
Expand All @@ -691,7 +705,7 @@ const Tile = ({
sampleWeightExpression: getSampleWeightExpression(source),
dateRange,
granularity,
filters,
filters: sqlFilters,
variables: tileVariables,
});
}
Expand Down Expand Up @@ -732,13 +746,21 @@ const Tile = ({
? source.bodyExpression
: undefined,
sampleWeightExpression: getSampleWeightExpression(source),
filters,
filters: sqlFilters,
variables: tileVariables,
metricTables: isMetricSource ? source.metricTables : undefined,
});
}
}
}, [source, chart, dateRange, granularity, filters, tileVariables]);
}, [
source,
chart,
dateRange,
granularity,
sqlFilters,
promqlFilters,
tileVariables,
]);

const [hovered, setHovered] = useState(false);

Expand Down Expand Up @@ -809,7 +831,7 @@ const Tile = ({
where: builderConfig?.where,
whereLanguage: builderConfig?.whereLanguage,
select: builderConfig?.select,
filters,
filters: sqlFilters,
},
);

Expand All @@ -819,10 +841,10 @@ const Tile = ({
);

const filterWarning = useMemo(() => {
const doFiltersExist = !!filters?.filter(
const doFiltersExist = !!sqlFilters?.filter(
Comment thread
pulpdrew marked this conversation as resolved.
f => (f.type === 'lucene' || f.type === 'sql') && f.condition.trim(),
Comment thread
pulpdrew marked this conversation as resolved.
)?.length;
const doLuceneFiltersExist = !!filters?.filter(
const doLuceneFiltersExist = !!sqlFilters?.filter(
f => f.type === 'lucene' && f.condition.trim(),
)?.length;

Expand Down Expand Up @@ -858,7 +880,7 @@ const Tile = ({
<IconZoomExclamation size={16} color="var(--color-text-danger)" />
</Tooltip>
);
}, [filters, queriedConfig, source]);
}, [sqlFilters, queriedConfig, source]);

const replaySearchUrl = useMemo(() => {
return buildDashboardReplaySearchUrl({
Expand Down Expand Up @@ -1548,7 +1570,7 @@ const EditTileModal = ({
isSaving?: boolean;
onSave: (chart: Tile) => void;
variables?: ChartVariable[];
getDashboardFilters: (sourceId: string | undefined) => Filter[];
getDashboardFilters: (sourceId: string | undefined) => BroadcastFilter[];
unsatisfiedRequiredFilters?: DashboardFilter[];
}) => {
const contextZIndex = useZIndex();
Expand Down Expand Up @@ -2304,7 +2326,7 @@ function DBDashboardPage({
// `sourceId`. Shared with the tile editor so its preview queries what the
// tile does.
const getTileFilters = useCallback(
(sourceId: string | undefined): Filter[] => [
(sourceId: string | undefined): BroadcastFilter[] => [
{
type: whereLanguage === 'sql' ? 'sql' : 'lucene',
condition: where,
Expand Down
3 changes: 0 additions & 3 deletions packages/app/src/__tests__/searchFilters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,6 @@ describe('searchFilters', () => {

const query = filtersToQuery(originalFilters, { dateTimeColumns });
// Sanity: produces the DateTime (non-64) wrapper.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect((query[0] as ConditionFilter).condition).toBe(
"TimestampTime IN (parseDateTimeBestEffort('2026-06-17T11:56:41Z'))",
);
Expand Down Expand Up @@ -930,7 +929,6 @@ describe('searchFilters', () => {
const query = filtersToQuery(originalFilters, {
dateTimeColumns: aliasColumns,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect((query[0] as ConditionFilter).condition).toBe(
"time IN (parseDateTime64BestEffort('2026-06-18T10:33:55Z', 9))",
);
Expand All @@ -957,7 +955,6 @@ describe('searchFilters', () => {
const query = filtersToQuery(originalFilters, {
dateTimeColumns: exprColumns,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect((query[0] as ConditionFilter).condition).toBe(
"toDate(TimestampTime) IN (toDate('2026-06-18'))",
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { isAdhocConditionSupported } from '@hyperdx/common-utils/dist/filters';
import {
ADHOC_FILTER_MAX_CONDITIONS,
AdhocDashboardFilter as AdhocDashboardFilterType,
Expand Down Expand Up @@ -103,6 +104,10 @@ export function AdhocDashboardFilter({
operator={operator.label}
value={condition.value}
isExcluded={operator.isExcluded}
isInvalid={
!isAdhocConditionSupported(filter.sourceType, condition)
}
invalidReason={`Filter not applied: PromQL has no ${condition.operator} operator. Remove this condition and add it again with a supported operator.`}
onRemove={() => remove(index)}
renderPopover={close => (
<AdhocConditionEditor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@ const editorId = 'adhoc-condition-editor-Conditions';

const addButton = () => screen.getByTestId('adhoc-filter-add-Conditions');

const renderFilter = (conditions: AdhocFilterCondition[]) => {
const renderFilter = (
conditions: AdhocFilterCondition[],
definition: AdhocDashboardFilterType = filter,
) => {
const onChange = jest.fn();
renderWithMantine(
<AdhocDashboardFilter
filter={filter}
filter={definition}
conditions={conditions}
onChange={onChange}
dateRange={dateRange}
Expand Down Expand Up @@ -74,6 +77,20 @@ describe('AdhocDashboardFilter', () => {
expect(pills[1]).toHaveTextContent('Level regex ^err');
});

it('marks conditions a PromQL filter cannot apply as invalid', () => {
renderFilter(
[
{ key: 'job', operator: 'LIKE', value: '%api%' },
{ key: 'env', operator: '=', value: 'prod' },
],
{ ...filter, sourceType: 'promql' },
);

const pills = screen.getAllByTestId('adhoc-condition-pill');
expect(pills[0]).toHaveAttribute('data-invalid', 'true');
expect(pills[1]).not.toHaveAttribute('data-invalid');
});

it('removes a condition', async () => {
const onChange = renderFilter([
existing,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ describe('getSqlSourceColumnKeys', () => {
]),
).toEqual(['ServiceName']);
});

it('quotes column names that are not bare identifiers', () => {
expect(getSqlSourceColumnKeys([column('service-name', 'String')])).toEqual([
'`service-name`',
]);
});
});

describe('getValueSuggestions', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,26 +45,37 @@ const unionSorted = (lists: (string[] | undefined)[]) =>
Array.from(new Set(lists.flatMap(list => list ?? []))).sort();

/**
* A SQL source's filterable keys, in the search sidebar's key format: scalar
* columns, and the keys inside its map and JSON columns.
* Quote a column name that isn't a bare identifier (e.g. `log-attributes`), so
* the stored key is SQL that broadcasting can use verbatim.
*/
const toQuotedKeys = (keys: string[], columns: ColumnMeta[]) => {
Comment thread
pulpdrew marked this conversation as resolved.
const columnNames = new Set(columns.map(column => column.name));
return keys.map(key => toQuotedClickHouseKeyExpression(key, columnNames));
};

/**
* A SQL source's filterable keys, as SQL expressions in the search sidebar's
* key format: scalar columns, and the keys inside its map and JSON columns.
*/
export function getSqlSourceKeys(fields: Field[], columns: ColumnMeta[]) {
const columnNames = (types: JSDataType[]) =>
filterColumnMetaByType(columns, types)?.map(column => column.name) ?? [];
const jsonColumns = columnNames([JSDataType.JSON]);
const mapColumns = columnNames([JSDataType.Map]);
return fields
const keys = fields
.filter(field => field.path.length > 1 || isJSDataTypeScalar(field.jsType))
.map(field => mergePath(field.path, jsonColumns, mapColumns));
return toQuotedKeys(keys, columns);
}

/** A SQL source's scalar columns, the only keys known without field metadata. */
export function getSqlSourceColumnKeys(columns: ColumnMeta[]) {
return columns
const keys = columns
.filter(column =>
isJSDataTypeScalar(convertCHDataTypeToJSType(column.type)),
)
.map(column => column.name);
return toQuotedKeys(keys, columns);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ import {
isRawSqlSavedChartConfig,
} from '@hyperdx/common-utils/dist/guards';
import {
BroadcastFilter,
ChartConfigWithDateRange,
ChartVariable,
DashboardFilter,
DisplayType,
Filter,
PromqlHeatmapMode,
SavedChartConfig,
SourceKind,
Expand Down Expand Up @@ -111,6 +111,7 @@ import {
displayTypeToActiveTab,
resolveTilePreviewFilters,
TABS_WITH_GENERATED_SQL,
withBroadcastFilters,
zSavedChartConfig,
} from './utils';

Expand All @@ -120,7 +121,7 @@ type EditTimeChartFormProps = {
/** Variables and their selected values, from the parent dashboard (if one exists). */
variables?: ChartVariable[];
/** Function returning the dashboard's broadcast filters for the given source, if any. */
getDashboardFilters?: (sourceId: string | undefined) => Filter[];
getDashboardFilters?: (sourceId: string | undefined) => BroadcastFilter[];
/** The dashboard's required filters that have nothing selected, if any */
unsatisfiedRequiredFilters?: DashboardFilter[];
displayedTimeInputValue?: string;
Expand Down Expand Up @@ -543,8 +544,7 @@ export default function EditTimeChartForm({
return queriedConfig;
}
return {
...queriedConfig,
filters: previewDashboardFilters?.filters,
...withBroadcastFilters(queriedConfig, previewDashboardFilters?.filters),
variables: previewDashboardFilters?.variables,
};
}, [queriedConfig, previewDashboardFilters]);
Expand Down
Loading
Loading