Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/lemon-gifts-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---

feat: Add conditions to Dashboard filters; Support filter multi-select
15 changes: 15 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1838,6 +1838,21 @@
],
"description": "Metric type when source is metrics",
"example": "gauge"
},
"where": {
"type": "string",
"description": "Optional WHERE condition to scope which rows this filter key reads values from",
"example": "ServiceName:api"
},
"whereLanguage": {
"type": "string",
"enum": [
"sql",
"lucene"
],
"description": "Language of the where condition",
"default": "sql",
"example": "lucene"
}
}
},
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/models/presetDashboardFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ const PresetDashboardFilterSchema = new Schema<IPresetDashboardFilter>(
},
type: { type: String, required: true },
expression: { type: String, required: true },
where: { type: String, required: false },
whereLanguage: {
type: String,
required: false,
enum: ['sql', 'lucene'],
},
},
{
timestamps: true,
Expand Down
36 changes: 32 additions & 4 deletions packages/api/src/routers/external-api/__tests__/dashboards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,14 +848,22 @@ describe('External API v2 Dashboards - old format', () => {
sourceId: traceSource._id.toString(),
sourceMetricType: undefined,
},
{
type: 'QUERY_EXPRESSION' as const,
name: 'Region (Filtered)',
expression: 'region',
sourceId: traceSource._id.toString(),
where: "environment = 'production'",
whereLanguage: 'sql' as const,
},
],
};

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

expect(response.body.data.filters).toHaveLength(2);
expect(response.body.data.filters).toHaveLength(3);
response.body.data.filters.forEach(
(f: {
id: string;
Expand All @@ -879,12 +887,18 @@ describe('External API v2 Dashboards - old format', () => {
expect(response.body.data.filters[0].expression).toBe('environment');
expect(response.body.data.filters[1].name).toBe('Service Filter');
expect(response.body.data.filters[1].expression).toBe('service_name');
expect(response.body.data.filters[2].name).toBe('Region (Filtered)');
expect(response.body.data.filters[2].expression).toBe('region');
expect(response.body.data.filters[2].where).toBe(
"environment = 'production'",
);
expect(response.body.data.filters[2].whereLanguage).toBe('sql');

const getResponse = await authRequest(
'get',
`${BASE_URL}/${response.body.data.id}`,
).expect(200);
expect(getResponse.body.data.filters).toHaveLength(2);
expect(getResponse.body.data.filters).toHaveLength(3);
expect(getResponse.body.data.filters).toEqual(response.body.data.filters);
});

Expand Down Expand Up @@ -2519,14 +2533,22 @@ describe('External API v2 Dashboards - new format', () => {
sourceId: traceSource._id.toString(),
sourceMetricType: undefined,
},
{
type: 'QUERY_EXPRESSION' as const,
name: 'Region (Filtered)',
expression: 'region',
sourceId: traceSource._id.toString(),
where: "environment = 'production'",
whereLanguage: 'sql' as const,
},
],
};

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

expect(response.body.data.filters).toHaveLength(2);
expect(response.body.data.filters).toHaveLength(3);
response.body.data.filters.forEach(
(f: {
id: string;
Expand All @@ -2550,12 +2572,18 @@ describe('External API v2 Dashboards - new format', () => {
expect(response.body.data.filters[0].expression).toBe('environment');
expect(response.body.data.filters[1].name).toBe('Service Filter');
expect(response.body.data.filters[1].expression).toBe('service_name');
expect(response.body.data.filters[2].name).toBe('Region (Filtered)');
expect(response.body.data.filters[2].expression).toBe('region');
expect(response.body.data.filters[2].where).toBe(
"environment = 'production'",
);
expect(response.body.data.filters[2].whereLanguage).toBe('sql');

const getResponse = await authRequest(
'get',
`${BASE_URL}/${response.body.data.id}`,
).expect(200);
expect(getResponse.body.data.filters).toHaveLength(2);
expect(getResponse.body.data.filters).toHaveLength(3);
expect(getResponse.body.data.filters).toEqual(response.body.data.filters);
});

Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/routers/external-api/v2/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,16 @@ const updateDashboardBodySchema = buildDashboardBodySchema(
* enum: [sum, gauge, histogram, summary, exponential histogram]
* description: Metric type when source is metrics
* example: "gauge"
* where:
* type: string
* description: Optional WHERE condition to scope which rows this filter key reads values from
* example: "ServiceName:api"
* whereLanguage:
* type: string
* enum: [sql, lucene]
* description: Language of the where condition
* default: "sql"
* example: "lucene"
*
* Filter:
* allOf:
Expand Down
36 changes: 17 additions & 19 deletions packages/app/src/DashboardFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
import { Group, Select } from '@mantine/core';
import { Group, MultiSelect } from '@mantine/core';
import { IconRefresh } from '@tabler/icons-react';

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

interface DashboardFilterSelectProps {
filter: DashboardFilter;
onChange: (value: string | null) => void;
value?: string | null;
onChange: (values: string[]) => void;
value: string[];
values?: string[];
isLoading?: boolean;
}
Expand All @@ -26,18 +26,17 @@ const DashboardFilterSelect = ({
}));

return (
<Select
placeholder={filter.name}
value={value ?? null} // null clears the select, undefined makes the select uncontrolled
<MultiSelect
placeholder={value.length === 0 ? filter.name : undefined}
value={value}
data={selectValues || []}
searchable
clearable
allowDeselect
size="xs"
maxDropdownHeight={280}
disabled={isLoading}
variant="filled"
w={200}
w={250}
limit={20}
onChange={onChange}
data-testid={`dashboard-filter-select-${filter.name}`}
Expand All @@ -48,7 +47,7 @@ const DashboardFilterSelect = ({
interface DashboardFilterProps {
filters: DashboardFilter[];
filterValues: FilterState;
onSetFilterValue: (expression: string, value: string | null) => void;
onSetFilterValue: (expression: string, values: string[]) => void;
dateRange: [Date, Date];
}

Expand All @@ -58,28 +57,27 @@ const DashboardFilters = ({
filterValues,
onSetFilterValue,
}: DashboardFilterProps) => {
const { data: filterValuesBySource, isFetching } = useDashboardFilterValues({
const { data: filterValuesById, isFetching } = useDashboardFilterValues({
filters,
dateRange,
});

return (
<Group mt="sm">
<Group mt="sm" align="start">
{Object.values(filters).map(filter => {
const queriedFilterValues = filterValuesBySource?.get(
filter.expression,
);
const queriedFilterValues = filterValuesById?.get(filter.id);
const included = filterValues[filter.expression]?.included;
const selectedValues = included
? Array.from(included).map(v => v.toString())
: [];
return (
<DashboardFilterSelect
key={filter.id}
filter={filter}
isLoading={!queriedFilterValues}
onChange={value => onSetFilterValue(filter.expression, value)}
onChange={values => onSetFilterValue(filter.expression, values)}
values={queriedFilterValues?.values}
value={filterValues[filter.expression]?.included
.values()
.next()
.value?.toString()}
value={selectedValues}
/>
);
})}
Expand Down
48 changes: 44 additions & 4 deletions packages/app/src/DashboardFiltersModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import {
IconTrash,
} from '@tabler/icons-react';

import SearchWhereInput, {
getStoredLanguage,
} from '@/components/SearchInput/SearchWhereInput';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';

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

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

const MODAL_SIZE = 'sm';
const MODAL_SIZE = 'md';

interface CustomInputWrapperProps {
children: React.ReactNode;
Expand Down Expand Up @@ -97,11 +100,19 @@ const DashboardFilterEditForm = ({
}: DashboardFilterEditFormProps) => {
const { handleSubmit, register, formState, control, reset } =
useForm<DashboardFilter>({
defaultValues: filter,
defaultValues: {
...filter,
where: filter.where ?? '',
whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql',
},
});

useEffect(() => {
reset(filter);
reset({
...filter,
where: filter.where ?? '',
whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql',
});
}, [filter, reset]);

const sourceId = useWatch({ control, name: 'source' });
Expand Down Expand Up @@ -134,7 +145,18 @@ const DashboardFilterEditForm = ({
size={MODAL_SIZE}
>
<div ref={setModalContentRef}>
<form onSubmit={handleSubmit(onSave)}>
<form
onSubmit={handleSubmit(values => {
const trimmedWhere = values.where?.trim() ?? '';
onSave({
...values,
where: trimmedWhere || undefined,
whereLanguage: trimmedWhere
? (values.whereLanguage ?? 'sql')
: undefined,
});
})}
>
<Stack>
<CustomInputWrapper label="Name" error={formState.errors.name}>
<TextInput
Expand Down Expand Up @@ -204,6 +226,22 @@ const DashboardFilterEditForm = ({
/>
</CustomInputWrapper>

<CustomInputWrapper
label="Dropdown values filter"
tooltipText="Optional condition used to filter the rows from which available filter values are queried"
>
<SearchWhereInput
tableConnection={tableConnection}
control={control}
name="where"
languageName="whereLanguage"
showLabel={false}
allowMultiline={true}
sqlPlaceholder="Filter for dropdown values"
lucenePlaceholder="Filter for dropdown values"
/>
</CustomInputWrapper>

<Group justify="space-between" my="xs">
<Button variant="secondary" onClick={onCancel}>
Cancel
Expand Down Expand Up @@ -394,6 +432,8 @@ const DashboardFiltersModal = ({
name: '',
expression: '',
source: source?.id ?? '',
where: '',
whereLanguage: getStoredLanguage() ?? 'sql',
});
};

Expand Down
Loading
Loading