Skip to content

Commit 9205823

Browse files
authored
feat: Broadcast adhoc dashboard filter conditions (#3366)
1 parent fe59feb commit 9205823

27 files changed

Lines changed: 1211 additions & 112 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hyperdx/common-utils': patch
3+
'@hyperdx/app': patch
4+
---
5+
6+
feat: Broadcast adhoc dashboard filter conditions

‎packages/app/src/DBDashboardPage.tsx‎

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ import {
3535
isTimeSeriesDisplayType,
3636
} from '@hyperdx/common-utils/dist/core/utils';
3737
import { getBlockingRequiredFilterNames } from '@hyperdx/common-utils/dist/dashboardFilterValues';
38+
import {
39+
getPromqlFilters,
40+
getSqlFilters,
41+
} from '@hyperdx/common-utils/dist/filters';
3842
import {
3943
displayTypeRequiresSource,
4044
isBuilderChartConfig,
@@ -51,12 +55,12 @@ import {
5155
import { isMissingFiltersMacro } from '@hyperdx/common-utils/dist/macros';
5256
import {
5357
AlertState,
58+
BroadcastFilter,
5459
ChartConfigWithDateRange,
5560
ChartVariable,
5661
DashboardContainer as DashboardContainerSchema,
5762
DashboardFilter,
5863
DisplayType,
59-
Filter,
6064
getSampleWeightExpression,
6165
isLogSource,
6266
isTraceSource,
@@ -467,7 +471,7 @@ const Tile = ({
467471
onSettled?: () => void;
468472
granularity: SQLInterval | undefined;
469473
onTimeRangeSelect: (start: Date, end: Date) => void;
470-
filters?: Filter[];
474+
filters?: BroadcastFilter[];
471475
variables?: ChartVariable[];
472476
/** The dashboard's required filters that have nothing selected. */
473477
unsatisfiedRequiredFilters?: DashboardFilter[];
@@ -650,6 +654,15 @@ const Tile = ({
650654
missingRequiredFilterNames.length > 0 &&
651655
displayTypeRequiresSource(chart.config.displayType);
652656

657+
const sqlFilters = useMemo(
658+
() => (filters ? getSqlFilters(filters) : undefined),
659+
[filters],
660+
);
661+
const promqlFilters = useMemo(
662+
() => (filters ? getPromqlFilters(filters) : undefined),
663+
[filters],
664+
);
665+
653666
useEffect(() => {
654667
if (isPromqlSavedChartConfig(chart.config)) {
655668
if (source != null) {
@@ -660,6 +673,7 @@ const Tile = ({
660673
dateRange,
661674
granularity,
662675
variables: tileVariables,
676+
filters: promqlFilters,
663677
});
664678
}
665679
return;
@@ -672,7 +686,7 @@ const Tile = ({
672686
...chart.config,
673687
dateRange,
674688
granularity,
675-
filters,
689+
filters: sqlFilters,
676690
variables: tileVariables,
677691
});
678692
} else if (source != null) {
@@ -691,7 +705,7 @@ const Tile = ({
691705
sampleWeightExpression: getSampleWeightExpression(source),
692706
dateRange,
693707
granularity,
694-
filters,
708+
filters: sqlFilters,
695709
variables: tileVariables,
696710
});
697711
}
@@ -732,13 +746,21 @@ const Tile = ({
732746
? source.bodyExpression
733747
: undefined,
734748
sampleWeightExpression: getSampleWeightExpression(source),
735-
filters,
749+
filters: sqlFilters,
736750
variables: tileVariables,
737751
metricTables: isMetricSource ? source.metricTables : undefined,
738752
});
739753
}
740754
}
741-
}, [source, chart, dateRange, granularity, filters, tileVariables]);
755+
}, [
756+
source,
757+
chart,
758+
dateRange,
759+
granularity,
760+
sqlFilters,
761+
promqlFilters,
762+
tileVariables,
763+
]);
742764

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

@@ -809,7 +831,7 @@ const Tile = ({
809831
where: builderConfig?.where,
810832
whereLanguage: builderConfig?.whereLanguage,
811833
select: builderConfig?.select,
812-
filters,
834+
filters: sqlFilters,
813835
},
814836
);
815837

@@ -819,10 +841,10 @@ const Tile = ({
819841
);
820842

821843
const filterWarning = useMemo(() => {
822-
const doFiltersExist = !!filters?.filter(
844+
const doFiltersExist = !!sqlFilters?.filter(
823845
f => (f.type === 'lucene' || f.type === 'sql') && f.condition.trim(),
824846
)?.length;
825-
const doLuceneFiltersExist = !!filters?.filter(
847+
const doLuceneFiltersExist = !!sqlFilters?.filter(
826848
f => f.type === 'lucene' && f.condition.trim(),
827849
)?.length;
828850

@@ -858,7 +880,7 @@ const Tile = ({
858880
<IconZoomExclamation size={16} color="var(--color-text-danger)" />
859881
</Tooltip>
860882
);
861-
}, [filters, queriedConfig, source]);
883+
}, [sqlFilters, queriedConfig, source]);
862884

863885
const replaySearchUrl = useMemo(() => {
864886
return buildDashboardReplaySearchUrl({
@@ -1548,7 +1570,7 @@ const EditTileModal = ({
15481570
isSaving?: boolean;
15491571
onSave: (chart: Tile) => void;
15501572
variables?: ChartVariable[];
1551-
getDashboardFilters: (sourceId: string | undefined) => Filter[];
1573+
getDashboardFilters: (sourceId: string | undefined) => BroadcastFilter[];
15521574
unsatisfiedRequiredFilters?: DashboardFilter[];
15531575
}) => {
15541576
const contextZIndex = useZIndex();
@@ -2304,7 +2326,7 @@ function DBDashboardPage({
23042326
// `sourceId`. Shared with the tile editor so its preview queries what the
23052327
// tile does.
23062328
const getTileFilters = useCallback(
2307-
(sourceId: string | undefined): Filter[] => [
2329+
(sourceId: string | undefined): BroadcastFilter[] => [
23082330
{
23092331
type: whereLanguage === 'sql' ? 'sql' : 'lucene',
23102332
condition: where,

‎packages/app/src/__tests__/searchFilters.test.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -886,7 +886,6 @@ describe('searchFilters', () => {
886886

887887
const query = filtersToQuery(originalFilters, { dateTimeColumns });
888888
// Sanity: produces the DateTime (non-64) wrapper.
889-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
890889
expect((query[0] as ConditionFilter).condition).toBe(
891890
"TimestampTime IN (parseDateTimeBestEffort('2026-06-17T11:56:41Z'))",
892891
);
@@ -930,7 +929,6 @@ describe('searchFilters', () => {
930929
const query = filtersToQuery(originalFilters, {
931930
dateTimeColumns: aliasColumns,
932931
});
933-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
934932
expect((query[0] as ConditionFilter).condition).toBe(
935933
"time IN (parseDateTime64BestEffort('2026-06-18T10:33:55Z', 9))",
936934
);
@@ -957,7 +955,6 @@ describe('searchFilters', () => {
957955
const query = filtersToQuery(originalFilters, {
958956
dateTimeColumns: exprColumns,
959957
});
960-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
961958
expect((query[0] as ConditionFilter).condition).toBe(
962959
"toDate(TimestampTime) IN (toDate('2026-06-18'))",
963960
);

‎packages/app/src/components/AdhocDashboardFilter/AdhocDashboardFilter.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState } from 'react';
2+
import { isAdhocConditionSupported } from '@hyperdx/common-utils/dist/filters';
23
import {
34
ADHOC_FILTER_MAX_CONDITIONS,
45
AdhocDashboardFilter as AdhocDashboardFilterType,
@@ -103,6 +104,10 @@ export function AdhocDashboardFilter({
103104
operator={operator.label}
104105
value={condition.value}
105106
isExcluded={operator.isExcluded}
107+
isInvalid={
108+
!isAdhocConditionSupported(filter.sourceType, condition)
109+
}
110+
invalidReason={`Filter not applied: PromQL has no ${condition.operator} operator. Remove this condition and add it again with a supported operator.`}
106111
onRemove={() => remove(index)}
107112
renderPopover={close => (
108113
<AdhocConditionEditor

‎packages/app/src/components/AdhocDashboardFilter/__tests__/AdhocDashboardFilter.test.tsx‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,14 @@ const editorId = 'adhoc-condition-editor-Conditions';
4040

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

43-
const renderFilter = (conditions: AdhocFilterCondition[]) => {
43+
const renderFilter = (
44+
conditions: AdhocFilterCondition[],
45+
definition: AdhocDashboardFilterType = filter,
46+
) => {
4447
const onChange = jest.fn();
4548
renderWithMantine(
4649
<AdhocDashboardFilter
47-
filter={filter}
50+
filter={definition}
4851
conditions={conditions}
4952
onChange={onChange}
5053
dateRange={dateRange}
@@ -74,6 +77,20 @@ describe('AdhocDashboardFilter', () => {
7477
expect(pills[1]).toHaveTextContent('Level regex ^err');
7578
});
7679

80+
it('marks conditions a PromQL filter cannot apply as invalid', () => {
81+
renderFilter(
82+
[
83+
{ key: 'job', operator: 'LIKE', value: '%api%' },
84+
{ key: 'env', operator: '=', value: 'prod' },
85+
],
86+
{ ...filter, sourceType: 'promql' },
87+
);
88+
89+
const pills = screen.getAllByTestId('adhoc-condition-pill');
90+
expect(pills[0]).toHaveAttribute('data-invalid', 'true');
91+
expect(pills[1]).not.toHaveAttribute('data-invalid');
92+
});
93+
7794
it('removes a condition', async () => {
7895
const onChange = renderFilter([
7996
existing,

‎packages/app/src/components/AdhocDashboardFilter/__tests__/useAdhocFilterOptions.test.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ describe('getSqlSourceColumnKeys', () => {
8686
]),
8787
).toEqual(['ServiceName']);
8888
});
89+
90+
it('quotes column names that are not bare identifiers', () => {
91+
expect(getSqlSourceColumnKeys([column('service-name', 'String')])).toEqual([
92+
'`service-name`',
93+
]);
94+
});
8995
});
9096

9197
describe('getValueSuggestions', () => {

‎packages/app/src/components/AdhocDashboardFilter/useAdhocFilterOptions.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,26 +45,37 @@ const unionSorted = (lists: (string[] | undefined)[]) =>
4545
Array.from(new Set(lists.flatMap(list => list ?? []))).sort();
4646

4747
/**
48-
* A SQL source's filterable keys, in the search sidebar's key format: scalar
49-
* columns, and the keys inside its map and JSON columns.
48+
* Quote a column name that isn't a bare identifier (e.g. `log-attributes`), so
49+
* the stored key is SQL that broadcasting can use verbatim.
50+
*/
51+
const toQuotedKeys = (keys: string[], columns: ColumnMeta[]) => {
52+
const columnNames = new Set(columns.map(column => column.name));
53+
return keys.map(key => toQuotedClickHouseKeyExpression(key, columnNames));
54+
};
55+
56+
/**
57+
* A SQL source's filterable keys, as SQL expressions in the search sidebar's
58+
* key format: scalar columns, and the keys inside its map and JSON columns.
5059
*/
5160
export function getSqlSourceKeys(fields: Field[], columns: ColumnMeta[]) {
5261
const columnNames = (types: JSDataType[]) =>
5362
filterColumnMetaByType(columns, types)?.map(column => column.name) ?? [];
5463
const jsonColumns = columnNames([JSDataType.JSON]);
5564
const mapColumns = columnNames([JSDataType.Map]);
56-
return fields
65+
const keys = fields
5766
.filter(field => field.path.length > 1 || isJSDataTypeScalar(field.jsType))
5867
.map(field => mergePath(field.path, jsonColumns, mapColumns));
68+
return toQuotedKeys(keys, columns);
5969
}
6070

6171
/** A SQL source's scalar columns, the only keys known without field metadata. */
6272
export function getSqlSourceColumnKeys(columns: ColumnMeta[]) {
63-
return columns
73+
const keys = columns
6474
.filter(column =>
6575
isJSDataTypeScalar(convertCHDataTypeToJSType(column.type)),
6676
)
6777
.map(column => column.name);
78+
return toQuotedKeys(keys, columns);
6879
}
6980

7081
/**

‎packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ import {
2121
isRawSqlSavedChartConfig,
2222
} from '@hyperdx/common-utils/dist/guards';
2323
import {
24+
BroadcastFilter,
2425
ChartConfigWithDateRange,
2526
ChartVariable,
2627
DashboardFilter,
2728
DisplayType,
28-
Filter,
2929
PromqlHeatmapMode,
3030
SavedChartConfig,
3131
SourceKind,
@@ -111,6 +111,7 @@ import {
111111
displayTypeToActiveTab,
112112
resolveTilePreviewFilters,
113113
TABS_WITH_GENERATED_SQL,
114+
withBroadcastFilters,
114115
zSavedChartConfig,
115116
} from './utils';
116117

@@ -120,7 +121,7 @@ type EditTimeChartFormProps = {
120121
/** Variables and their selected values, from the parent dashboard (if one exists). */
121122
variables?: ChartVariable[];
122123
/** Function returning the dashboard's broadcast filters for the given source, if any. */
123-
getDashboardFilters?: (sourceId: string | undefined) => Filter[];
124+
getDashboardFilters?: (sourceId: string | undefined) => BroadcastFilter[];
124125
/** The dashboard's required filters that have nothing selected, if any */
125126
unsatisfiedRequiredFilters?: DashboardFilter[];
126127
displayedTimeInputValue?: string;
@@ -543,8 +544,7 @@ export default function EditTimeChartForm({
543544
return queriedConfig;
544545
}
545546
return {
546-
...queriedConfig,
547-
filters: previewDashboardFilters?.filters,
547+
...withBroadcastFilters(queriedConfig, previewDashboardFilters?.filters),
548548
variables: previewDashboardFilters?.variables,
549549
};
550550
}, [queriedConfig, previewDashboardFilters]);

0 commit comments

Comments
 (0)