Skip to content

Commit c6872fe

Browse files
committed
feat: Add conditions to Dashboard filters; Support filter multi-select
1 parent c70429e commit c6872fe

File tree

12 files changed

+395
-186
lines changed

12 files changed

+395
-186
lines changed

.changeset/lemon-gifts-suffer.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hyperdx/common-utils": patch
3+
"@hyperdx/api": patch
4+
"@hyperdx/app": patch
5+
---
6+
7+
feat: Add conditions to Dashboard filters; Support filter multi-select

packages/api/openapi.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1838,6 +1838,21 @@
18381838
],
18391839
"description": "Metric type when source is metrics",
18401840
"example": "gauge"
1841+
},
1842+
"where": {
1843+
"type": "string",
1844+
"description": "Optional WHERE condition to scope which rows this filter key reads values from",
1845+
"example": "ServiceName:api"
1846+
},
1847+
"whereLanguage": {
1848+
"type": "string",
1849+
"enum": [
1850+
"sql",
1851+
"lucene"
1852+
],
1853+
"description": "Language of the where condition",
1854+
"default": "sql",
1855+
"example": "lucene"
18411856
}
18421857
}
18431858
},

packages/api/src/models/presetDashboardFilter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ const PresetDashboardFilterSchema = new Schema<IPresetDashboardFilter>(
4242
},
4343
type: { type: String, required: true },
4444
expression: { type: String, required: true },
45+
where: { type: String, required: false },
46+
whereLanguage: {
47+
type: String,
48+
required: false,
49+
enum: ['sql', 'lucene'],
50+
},
4551
},
4652
{
4753
timestamps: true,

packages/api/src/routers/external-api/__tests__/dashboards.test.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -848,14 +848,22 @@ describe('External API v2 Dashboards - old format', () => {
848848
sourceId: traceSource._id.toString(),
849849
sourceMetricType: undefined,
850850
},
851+
{
852+
type: 'QUERY_EXPRESSION' as const,
853+
name: 'Region (Filtered)',
854+
expression: 'region',
855+
sourceId: traceSource._id.toString(),
856+
where: "environment = 'production'",
857+
whereLanguage: 'sql' as const,
858+
},
851859
],
852860
};
853861

854862
const response = await authRequest('post', BASE_URL)
855863
.send(dashboardPayload)
856864
.expect(200);
857865

858-
expect(response.body.data.filters).toHaveLength(2);
866+
expect(response.body.data.filters).toHaveLength(3);
859867
response.body.data.filters.forEach(
860868
(f: {
861869
id: string;
@@ -879,12 +887,18 @@ describe('External API v2 Dashboards - old format', () => {
879887
expect(response.body.data.filters[0].expression).toBe('environment');
880888
expect(response.body.data.filters[1].name).toBe('Service Filter');
881889
expect(response.body.data.filters[1].expression).toBe('service_name');
890+
expect(response.body.data.filters[2].name).toBe('Region (Filtered)');
891+
expect(response.body.data.filters[2].expression).toBe('region');
892+
expect(response.body.data.filters[2].where).toBe(
893+
"environment = 'production'",
894+
);
895+
expect(response.body.data.filters[2].whereLanguage).toBe('sql');
882896

883897
const getResponse = await authRequest(
884898
'get',
885899
`${BASE_URL}/${response.body.data.id}`,
886900
).expect(200);
887-
expect(getResponse.body.data.filters).toHaveLength(2);
901+
expect(getResponse.body.data.filters).toHaveLength(3);
888902
expect(getResponse.body.data.filters).toEqual(response.body.data.filters);
889903
});
890904

@@ -2519,14 +2533,22 @@ describe('External API v2 Dashboards - new format', () => {
25192533
sourceId: traceSource._id.toString(),
25202534
sourceMetricType: undefined,
25212535
},
2536+
{
2537+
type: 'QUERY_EXPRESSION' as const,
2538+
name: 'Region (Filtered)',
2539+
expression: 'region',
2540+
sourceId: traceSource._id.toString(),
2541+
where: "environment = 'production'",
2542+
whereLanguage: 'sql' as const,
2543+
},
25222544
],
25232545
};
25242546

25252547
const response = await authRequest('post', BASE_URL)
25262548
.send(dashboardPayload)
25272549
.expect(200);
25282550

2529-
expect(response.body.data.filters).toHaveLength(2);
2551+
expect(response.body.data.filters).toHaveLength(3);
25302552
response.body.data.filters.forEach(
25312553
(f: {
25322554
id: string;
@@ -2550,12 +2572,18 @@ describe('External API v2 Dashboards - new format', () => {
25502572
expect(response.body.data.filters[0].expression).toBe('environment');
25512573
expect(response.body.data.filters[1].name).toBe('Service Filter');
25522574
expect(response.body.data.filters[1].expression).toBe('service_name');
2575+
expect(response.body.data.filters[2].name).toBe('Region (Filtered)');
2576+
expect(response.body.data.filters[2].expression).toBe('region');
2577+
expect(response.body.data.filters[2].where).toBe(
2578+
"environment = 'production'",
2579+
);
2580+
expect(response.body.data.filters[2].whereLanguage).toBe('sql');
25532581

25542582
const getResponse = await authRequest(
25552583
'get',
25562584
`${BASE_URL}/${response.body.data.id}`,
25572585
).expect(200);
2558-
expect(getResponse.body.data.filters).toHaveLength(2);
2586+
expect(getResponse.body.data.filters).toHaveLength(3);
25592587
expect(getResponse.body.data.filters).toEqual(response.body.data.filters);
25602588
});
25612589

packages/api/src/routers/external-api/v2/dashboards.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,16 @@ const updateDashboardBodySchema = buildDashboardBodySchema(
11561156
* enum: [sum, gauge, histogram, summary, exponential histogram]
11571157
* description: Metric type when source is metrics
11581158
* example: "gauge"
1159+
* where:
1160+
* type: string
1161+
* description: Optional WHERE condition to scope which rows this filter key reads values from
1162+
* example: "ServiceName:api"
1163+
* whereLanguage:
1164+
* type: string
1165+
* enum: [sql, lucene]
1166+
* description: Language of the where condition
1167+
* default: "sql"
1168+
* example: "lucene"
11591169
*
11601170
* Filter:
11611171
* allOf:

packages/app/src/DashboardFilters.tsx

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
2-
import { Group, Select } from '@mantine/core';
2+
import { Group, MultiSelect } from '@mantine/core';
33
import { IconRefresh } from '@tabler/icons-react';
44

55
import { useDashboardFilterValues } from './hooks/useDashboardFilterValues';
66
import { FilterState } from './searchFilters';
77

88
interface DashboardFilterSelectProps {
99
filter: DashboardFilter;
10-
onChange: (value: string | null) => void;
11-
value?: string | null;
10+
onChange: (values: string[]) => void;
11+
value: string[];
1212
values?: string[];
1313
isLoading?: boolean;
1414
}
@@ -26,18 +26,17 @@ const DashboardFilterSelect = ({
2626
}));
2727

2828
return (
29-
<Select
30-
placeholder={filter.name}
31-
value={value ?? null} // null clears the select, undefined makes the select uncontrolled
29+
<MultiSelect
30+
placeholder={value.length === 0 ? filter.name : undefined}
31+
value={value}
3232
data={selectValues || []}
3333
searchable
3434
clearable
35-
allowDeselect
3635
size="xs"
3736
maxDropdownHeight={280}
3837
disabled={isLoading}
3938
variant="filled"
40-
w={200}
39+
w={250}
4140
limit={20}
4241
onChange={onChange}
4342
data-testid={`dashboard-filter-select-${filter.name}`}
@@ -48,7 +47,7 @@ const DashboardFilterSelect = ({
4847
interface DashboardFilterProps {
4948
filters: DashboardFilter[];
5049
filterValues: FilterState;
51-
onSetFilterValue: (expression: string, value: string | null) => void;
50+
onSetFilterValue: (expression: string, values: string[]) => void;
5251
dateRange: [Date, Date];
5352
}
5453

@@ -58,28 +57,27 @@ const DashboardFilters = ({
5857
filterValues,
5958
onSetFilterValue,
6059
}: DashboardFilterProps) => {
61-
const { data: filterValuesBySource, isFetching } = useDashboardFilterValues({
60+
const { data: filterValuesById, isFetching } = useDashboardFilterValues({
6261
filters,
6362
dateRange,
6463
});
6564

6665
return (
67-
<Group mt="sm">
66+
<Group mt="sm" align="start">
6867
{Object.values(filters).map(filter => {
69-
const queriedFilterValues = filterValuesBySource?.get(
70-
filter.expression,
71-
);
68+
const queriedFilterValues = filterValuesById?.get(filter.id);
69+
const included = filterValues[filter.expression]?.included;
70+
const selectedValues = included
71+
? Array.from(included).map(v => v.toString())
72+
: [];
7273
return (
7374
<DashboardFilterSelect
7475
key={filter.id}
7576
filter={filter}
7677
isLoading={!queriedFilterValues}
77-
onChange={value => onSetFilterValue(filter.expression, value)}
78+
onChange={values => onSetFilterValue(filter.expression, values)}
7879
values={queriedFilterValues?.values}
79-
value={filterValues[filter.expression]?.included
80-
.values()
81-
.next()
82-
.value?.toString()}
80+
value={selectedValues}
8381
/>
8482
);
8583
})}

packages/app/src/DashboardFiltersModal.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ import {
3131
IconTrash,
3232
} from '@tabler/icons-react';
3333

34+
import SearchWhereInput, {
35+
getStoredLanguage,
36+
} from '@/components/SearchInput/SearchWhereInput';
3437
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
3538

3639
import SourceSchemaPreview from './components/SourceSchemaPreview';
@@ -40,7 +43,7 @@ import { getMetricTableName } from './utils';
4043

4144
import styles from '../styles/DashboardFiltersModal.module.scss';
4245

43-
const MODAL_SIZE = 'sm';
46+
const MODAL_SIZE = 'md';
4447

4548
interface CustomInputWrapperProps {
4649
children: React.ReactNode;
@@ -97,11 +100,19 @@ const DashboardFilterEditForm = ({
97100
}: DashboardFilterEditFormProps) => {
98101
const { handleSubmit, register, formState, control, reset } =
99102
useForm<DashboardFilter>({
100-
defaultValues: filter,
103+
defaultValues: {
104+
...filter,
105+
where: filter.where ?? '',
106+
whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql',
107+
},
101108
});
102109

103110
useEffect(() => {
104-
reset(filter);
111+
reset({
112+
...filter,
113+
where: filter.where ?? '',
114+
whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql',
115+
});
105116
}, [filter, reset]);
106117

107118
const sourceId = useWatch({ control, name: 'source' });
@@ -134,7 +145,18 @@ const DashboardFilterEditForm = ({
134145
size={MODAL_SIZE}
135146
>
136147
<div ref={setModalContentRef}>
137-
<form onSubmit={handleSubmit(onSave)}>
148+
<form
149+
onSubmit={handleSubmit(values => {
150+
const trimmedWhere = values.where?.trim() ?? '';
151+
onSave({
152+
...values,
153+
where: trimmedWhere || undefined,
154+
whereLanguage: trimmedWhere
155+
? (values.whereLanguage ?? 'sql')
156+
: undefined,
157+
});
158+
})}
159+
>
138160
<Stack>
139161
<CustomInputWrapper label="Name" error={formState.errors.name}>
140162
<TextInput
@@ -204,6 +226,22 @@ const DashboardFilterEditForm = ({
204226
/>
205227
</CustomInputWrapper>
206228

229+
<CustomInputWrapper
230+
label="Dropdown values filter"
231+
tooltipText="Optional condition used to filter the rows from which available filter values are queried"
232+
>
233+
<SearchWhereInput
234+
tableConnection={tableConnection}
235+
control={control}
236+
name="where"
237+
languageName="whereLanguage"
238+
showLabel={false}
239+
allowMultiline={true}
240+
sqlPlaceholder="Filter for dropdown values"
241+
lucenePlaceholder="Filter for dropdown values"
242+
/>
243+
</CustomInputWrapper>
244+
207245
<Group justify="space-between" my="xs">
208246
<Button variant="secondary" onClick={onCancel}>
209247
Cancel
@@ -394,6 +432,8 @@ const DashboardFiltersModal = ({
394432
name: '',
395433
expression: '',
396434
source: source?.id ?? '',
435+
where: '',
436+
whereLanguage: getStoredLanguage() ?? 'sql',
397437
});
398438
};
399439

0 commit comments

Comments
 (0)